From a8888d082ce42719f44fdfee0062e5bdebed9793 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Tue, 17 Feb 2026 15:09:53 +0700 Subject: [PATCH 1/2] feat: enable rich text formatting for campfire posts Send campfire messages as HTML by adding content_type field to the API and enabling the markdown-to-HTML converter. Also enable hard wraps in goldmark so single newlines produce
tags. --- cmd/campfire/post.go | 16 +++++++--------- internal/api/campfire.go | 8 +++++--- internal/api/interface.go | 2 +- internal/api/mock/client.go | 4 ++-- internal/api/modular.go | 2 +- internal/markdown/converter.go | 1 + internal/markdown/converter_test.go | 2 +- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/cmd/campfire/post.go b/cmd/campfire/post.go index 59bf182..8600f88 100644 --- a/cmd/campfire/post.go +++ b/cmd/campfire/post.go @@ -8,6 +8,7 @@ import ( "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" + "github.com/needmore/bc4/internal/markdown" "github.com/needmore/bc4/internal/parser" "github.com/spf13/cobra" ) @@ -105,17 +106,14 @@ func newPostCmd(f *factory.Factory) *cobra.Command { return fmt.Errorf("message cannot be empty") } - // For now, just post the plain content - // The API seems to be escaping HTML in campfire messages - // TODO: Investigate the correct way to send rich text to campfire - // converter := markdown.NewConverter() - // richContent, err := converter.MarkdownToRichText(content) - // if err != nil { - // return fmt.Errorf("failed to convert message: %w", err) - // } + converter := markdown.NewConverter() + richContent, err := converter.MarkdownToRichText(content) + if err != nil { + return fmt.Errorf("failed to convert message: %w", err) + } // Post the message - line, err := campfireOps.PostCampfireLine(f.Context(), projectID, campfireID, content) + line, err := campfireOps.PostCampfireLine(f.Context(), projectID, campfireID, richContent, "text/html") if err != nil { return fmt.Errorf("failed to post message: %w", err) } diff --git a/internal/api/campfire.go b/internal/api/campfire.go index ff6bfb6..f336c0c 100644 --- a/internal/api/campfire.go +++ b/internal/api/campfire.go @@ -43,7 +43,8 @@ type CampfireLine struct { // CampfireLineCreate represents the request body for creating a campfire line type CampfireLineCreate struct { - Content string `json:"content"` + Content string `json:"content"` + ContentType string `json:"content_type,omitempty"` } // ListCampfires returns all campfires for a project @@ -96,12 +97,13 @@ func (c *Client) GetCampfireLines(ctx context.Context, projectID string, campfir } // PostCampfireLine posts a new message to a campfire -func (c *Client) PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string) (*CampfireLine, error) { +func (c *Client) PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string, contentType string) (*CampfireLine, error) { var line CampfireLine path := fmt.Sprintf("/buckets/%s/chats/%d/lines.json", projectID, campfireID) payload := CampfireLineCreate{ - Content: content, + Content: content, + ContentType: contentType, } if err := c.Post(path, payload, &line); err != nil { diff --git a/internal/api/interface.go b/internal/api/interface.go index 73a4956..b73b04a 100644 --- a/internal/api/interface.go +++ b/internal/api/interface.go @@ -31,7 +31,7 @@ type APIClient interface { GetCampfire(ctx context.Context, projectID string, campfireID int64) (*Campfire, error) GetCampfireByName(ctx context.Context, projectID string, name string) (*Campfire, error) GetCampfireLines(ctx context.Context, projectID string, campfireID int64, limit int) ([]CampfireLine, error) - PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string) (*CampfireLine, error) + PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string, contentType string) (*CampfireLine, error) DeleteCampfireLine(ctx context.Context, projectID string, campfireID int64, lineID int64) error // Card table methods diff --git a/internal/api/mock/client.go b/internal/api/mock/client.go index ef1c19b..543afbe 100644 --- a/internal/api/mock/client.go +++ b/internal/api/mock/client.go @@ -292,8 +292,8 @@ func (m *MockClient) GetCampfireLines(ctx context.Context, projectID string, cam } // PostCampfireLine mock implementation -func (m *MockClient) PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string) (*api.CampfireLine, error) { - m.Calls = append(m.Calls, fmt.Sprintf("PostCampfireLine(%s, %d, %s)", projectID, campfireID, content)) +func (m *MockClient) PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string, contentType string) (*api.CampfireLine, error) { + m.Calls = append(m.Calls, fmt.Sprintf("PostCampfireLine(%s, %d, %s, %s)", projectID, campfireID, content, contentType)) if m.PostCampfireLineError != nil { return nil, m.PostCampfireLineError } diff --git a/internal/api/modular.go b/internal/api/modular.go index 623729e..d1d3368 100644 --- a/internal/api/modular.go +++ b/internal/api/modular.go @@ -42,7 +42,7 @@ type CampfireOperations interface { GetCampfire(ctx context.Context, projectID string, campfireID int64) (*Campfire, error) GetCampfireByName(ctx context.Context, projectID string, name string) (*Campfire, error) GetCampfireLines(ctx context.Context, projectID string, campfireID int64, limit int) ([]CampfireLine, error) - PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string) (*CampfireLine, error) + PostCampfireLine(ctx context.Context, projectID string, campfireID int64, content string, contentType string) (*CampfireLine, error) DeleteCampfireLine(ctx context.Context, projectID string, campfireID int64, lineID int64) error } diff --git a/internal/markdown/converter.go b/internal/markdown/converter.go index 8883386..a07833f 100644 --- a/internal/markdown/converter.go +++ b/internal/markdown/converter.go @@ -39,6 +39,7 @@ func NewConverter() Converter { ), goldmark.WithRendererOptions( html.WithXHTML(), + html.WithHardWraps(), ), ) diff --git a/internal/markdown/converter_test.go b/internal/markdown/converter_test.go index 9e5fa6f..e6493b8 100644 --- a/internal/markdown/converter_test.go +++ b/internal/markdown/converter_test.go @@ -125,7 +125,7 @@ func TestMarkdownToRichText(t *testing.T) { { name: "multiline blockquote", input: "> Line 1\n> Line 2", - expected: "
Line 1\nLine 2
", + expected: "
Line 1
Line 2
", }, { name: "blockquote with formatting", From 679f8827ad9b7a152cd3f3cf6eb483e7f0a7cf81 Mon Sep 17 00:00:00 2001 From: Nikita Nemirovsky Date: Tue, 17 Feb 2026 15:14:15 +0700 Subject: [PATCH 2/2] docs: update campfire markdown support status --- README.md | 2 +- docs/SPEC.md | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 897104e..c433e0a 100644 --- a/README.md +++ b/README.md @@ -795,7 +795,7 @@ bc4 supports Markdown input for creating content that gets automatically convert - ✅ **Messages** - List, post, view, and edit messages on project message boards - ✅ **Comments** - Create and edit comments with Markdown formatting - ✅ **Documents** - Create and edit documents with Markdown formatting -- ❌ **Campfire** - Plain text only (API limitation) +- ✅ **Campfire** - Post messages with Markdown formatting ### Supported Markdown Elements - **Bold** (`**text**`), *italic* (`*text*`), ~~strikethrough~~ (`~~text~~`) diff --git a/docs/SPEC.md b/docs/SPEC.md index 5f5740f..f60d8b7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -404,6 +404,7 @@ According to the Basecamp API, the following resources support rich text content **Currently Implemented:** - ✅ **Todo** - `content` (title) and `description` fields support Markdown input - ✅ **Comment** - `content` field supports Markdown input and bc-attachment tags +- ✅ **Campfire line** - uses `content_type: "text/html"` to send rich text **Future Implementation:** - 🔄 **Card** - `title` and `content` fields will support Markdown @@ -413,9 +414,6 @@ According to the Basecamp API, the following resources support rich text content - 🔄 **Upload** - `description` field will support Markdown - 🔄 **To-do list** - `description` field will support Markdown -**Not Supported (API Limitation):** -- ❌ **Campfire line** - `content` field is plain text only per API specification - ### Markdown to Rich Text Conversion The `internal/markdown` package provides bidirectional conversion: