diff --git a/cmd/campfire/post.go b/cmd/campfire/post.go index 8600f88..b3a732c 100644 --- a/cmd/campfire/post.go +++ b/cmd/campfire/post.go @@ -9,6 +9,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/mentions" "github.com/needmore/bc4/internal/parser" "github.com/spf13/cobra" ) @@ -112,6 +113,12 @@ func newPostCmd(f *factory.Factory) *cobra.Command { return fmt.Errorf("failed to convert message: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Post the message line, err := campfireOps.PostCampfireLine(f.Context(), projectID, campfireID, richContent, "text/html") if err != nil { diff --git a/cmd/card/add.go b/cmd/card/add.go index 6305de9..d7d6555 100644 --- a/cmd/card/add.go +++ b/cmd/card/add.go @@ -11,6 +11,7 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" @@ -174,6 +175,14 @@ can be attached by using the flag multiple times.`, richContent = rc } + // Replace inline @Name mentions with bc-attachment tags + if richContent != "" { + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + } + // Handle attachments if len(attach) > 0 { for _, attachPath := range attach { diff --git a/cmd/card/edit.go b/cmd/card/edit.go index ab3183d..fd29710 100644 --- a/cmd/card/edit.go +++ b/cmd/card/edit.go @@ -17,6 +17,7 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/spf13/cobra" ) @@ -101,6 +102,12 @@ func (m editModel) updateCard() tea.Cmd { return cardUpdatedMsg{err: err} } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(m.factory.Context(), richContent, m.client, m.projectID) + if err != nil { + return cardUpdatedMsg{err: err} + } + req := api.CardUpdateRequest{ Title: m.cardTitle, Content: richContent, @@ -381,6 +388,13 @@ func updateCardNonInteractive(f *factory.Factory, client *api.Client, projectID if err != nil { return fmt.Errorf("failed to convert content: %w", err) } + + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + req.Content = richContent } else { req.Content = card.Content diff --git a/cmd/comment/create.go b/cmd/comment/create.go index 7c757a9..9572b47 100644 --- a/cmd/comment/create.go +++ b/cmd/comment/create.go @@ -5,7 +5,6 @@ import ( "io" "os" "path/filepath" - "regexp" "strconv" "strings" @@ -14,14 +13,12 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" - "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" ) -var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`) - func newCreateCmd(f *factory.Factory) *cobra.Command { var content string var attachmentPath string @@ -126,25 +123,9 @@ You can provide comment content in several ways: } // Replace inline @Name mentions with bc-attachment tags - // Supports @FirstName and @First.Last for disambiguation - submatches := mentionRe.FindAllStringSubmatch(richContent, -1) - if len(submatches) > 0 { - resolver := utils.NewUserResolver(client.Client, projectID) - // Extract capture group (the @mention) and convert @First.Last to "First Last" - mentions := make([]string, len(submatches)) - identifiers := make([]string, len(submatches)) - for i, sm := range submatches { - mentions[i] = sm[1] - identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(sm[1], "@"), ".", " ") - } - people, err := resolver.ResolvePeople(f.Context(), identifiers) - if err != nil { - return fmt.Errorf("failed to resolve mentions: %w", err) - } - for i, match := range mentions { - tag := attachments.BuildTag(people[i].AttachableSGID) - richContent = strings.Replace(richContent, match, tag, 1) - } + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) } // Attach file if provided diff --git a/cmd/comment/edit.go b/cmd/comment/edit.go index 304a436..dd3443a 100644 --- a/cmd/comment/edit.go +++ b/cmd/comment/edit.go @@ -11,6 +11,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/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" @@ -124,6 +125,12 @@ You can provide updated content in several ways: return fmt.Errorf("failed to convert markdown: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Update the comment req := api.CommentUpdateRequest{ Content: richContent, diff --git a/cmd/document/create.go b/cmd/document/create.go index 3db4dd3..dd2b7f1 100644 --- a/cmd/document/create.go +++ b/cmd/document/create.go @@ -10,6 +10,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/mentions" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" ) @@ -107,6 +108,12 @@ You can provide document content in several ways: return fmt.Errorf("failed to convert markdown: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Create the document req := api.DocumentCreateRequest{ Title: title, diff --git a/cmd/document/edit.go b/cmd/document/edit.go index 6dc0f3c..51716ee 100644 --- a/cmd/document/edit.go +++ b/cmd/document/edit.go @@ -11,6 +11,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/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" @@ -132,6 +133,13 @@ You can provide updated content in several ways: if err != nil { return fmt.Errorf("failed to convert markdown: %w", err) } + + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + req.Content = richContent hasUpdate = true } diff --git a/cmd/message/edit.go b/cmd/message/edit.go index 48bcedc..96537d8 100644 --- a/cmd/message/edit.go +++ b/cmd/message/edit.go @@ -12,6 +12,7 @@ import ( "github.com/needmore/bc4/internal/cmdutil" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" @@ -137,6 +138,13 @@ cat updated.md | bc4 message edit 12345`, if err != nil { return fmt.Errorf("failed to convert markdown: %w", err) } + + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + req.Content = richContent hasUpdate = true } diff --git a/cmd/message/post.go b/cmd/message/post.go index 4257b05..8a3ce38 100644 --- a/cmd/message/post.go +++ b/cmd/message/post.go @@ -10,6 +10,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/mentions" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" ) @@ -107,6 +108,12 @@ You can provide message content in several ways: return fmt.Errorf("failed to convert markdown: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Create the message req := api.MessageCreateRequest{ Subject: title, diff --git a/cmd/todo/add.go b/cmd/todo/add.go index 82e7bbe..a8c2a2b 100644 --- a/cmd/todo/add.go +++ b/cmd/todo/add.go @@ -11,6 +11,7 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" @@ -232,6 +233,18 @@ func runAdd(f *factory.Factory, opts *addOptions, args []string) error { } } + // Replace inline @Name mentions with bc-attachment tags + richTitle, err = mentions.Resolve(f.Context(), richTitle, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + if richDescription != "" { + richDescription, err = mentions.Resolve(f.Context(), richDescription, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + } + // Handle attachments if len(opts.attach) > 0 { for _, attachPath := range opts.attach { diff --git a/cmd/todo/edit.go b/cmd/todo/edit.go index 6c87128..2f976a7 100644 --- a/cmd/todo/edit.go +++ b/cmd/todo/edit.go @@ -13,6 +13,7 @@ import ( "github.com/needmore/bc4/internal/cmdutil" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" @@ -187,6 +188,10 @@ func runEdit(f *factory.Factory, opts *editOptions, args []string) error { if err != nil { return fmt.Errorf("failed to convert title: %w", err) } + richTitle, err = mentions.Resolve(f.Context(), richTitle, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } req.Content = richTitle } @@ -196,6 +201,10 @@ func runEdit(f *factory.Factory, opts *editOptions, args []string) error { if err != nil { return fmt.Errorf("failed to convert description: %w", err) } + richDescription, err = mentions.Resolve(f.Context(), richDescription, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } req.Description = richDescription } diff --git a/internal/mentions/mentions.go b/internal/mentions/mentions.go new file mode 100644 index 0000000..752b315 --- /dev/null +++ b/internal/mentions/mentions.go @@ -0,0 +1,52 @@ +package mentions + +import ( + "context" + "regexp" + "strings" + + "github.com/needmore/bc4/internal/api" + "github.com/needmore/bc4/internal/attachments" + "github.com/needmore/bc4/internal/utils" +) + +// mentionRe matches @Name and @First.Last mentions in rich text content. +// Mentions must appear at the start of the string, after > (blockquotes), +// or after whitespace. +var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`) + +// Resolve finds @mentions in rich text content and replaces them with +// Basecamp bc-attachment tags. It uses the project's people list to +// resolve mention identifiers to their AttachableSGID values. +// +// Supports @FirstName and @First.Last syntax. Returns the content +// unchanged if no mentions are found. +func Resolve(ctx context.Context, richContent string, client api.APIClient, projectID string) (string, error) { + indexMatches := mentionRe.FindAllStringSubmatchIndex(richContent, -1) + if len(indexMatches) == 0 { + return richContent, nil + } + + resolver := utils.NewUserResolver(client, projectID) + + // Extract capture group (the @mention) and convert @First.Last to "First Last" + identifiers := make([]string, len(indexMatches)) + for i, loc := range indexMatches { + // loc[2]:loc[3] is the capture group (the @mention) + mention := richContent[loc[2]:loc[3]] + identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(mention, "@"), ".", " ") + } + + people, err := resolver.ResolvePeople(ctx, identifiers) + if err != nil { + return "", err + } + + // Replace from end to start so earlier indices remain valid + for i := len(indexMatches) - 1; i >= 0; i-- { + tag := attachments.BuildTag(people[i].AttachableSGID) + richContent = richContent[:indexMatches[i][2]] + tag + richContent[indexMatches[i][3]:] + } + + return richContent, nil +} diff --git a/internal/mentions/mentions_test.go b/internal/mentions/mentions_test.go new file mode 100644 index 0000000..837eddb --- /dev/null +++ b/internal/mentions/mentions_test.go @@ -0,0 +1,156 @@ +package mentions + +import ( + "context" + "errors" + "testing" + + "github.com/needmore/bc4/internal/api" + "github.com/needmore/bc4/internal/api/mock" +) + +func TestResolve(t *testing.T) { + people := []api.Person{ + {ID: 1, Name: "John Doe", EmailAddress: "john@example.com", AttachableSGID: "sgid-john"}, + {ID: 2, Name: "Jane Smith", EmailAddress: "jane@example.com", AttachableSGID: "sgid-jane"}, + {ID: 3, Name: "Bob Johnson", EmailAddress: "bob@company.com", AttachableSGID: "sgid-bob"}, + } + + tests := []struct { + name string + content string + mockPeople []api.Person + mockError error + expected string + expectError bool + }{ + { + name: "no mentions returns content unchanged", + content: "
Hello world
", + mockPeople: people, + expected: "Hello world
", + }, + { + name: "single @FirstName mention", + content: `Hey @John check this out
`, + mockPeople: people, + expected: `Hey
Hey @John.Doe check this out
`, + mockPeople: people, + expected: `Hey
Hey @John and @Jane
`, + mockPeople: people, + expected: `Hey
@John said something`, + mockPeople: people, + expected: `
`, + }, + { + name: "unknown mention returns error", + content: `said something
Hey @Unknown
`, + mockPeople: people, + expectError: true, + }, + { + name: "API error propagated", + content: `Hey @John
`, + mockError: errors.New("API error"), + expectError: true, + }, + { + name: "empty content returns empty", + content: "", + mockPeople: people, + expected: "", + }, + { + name: "content with email address not treated as mention", + content: `Email me at john@example.com
`, + mockPeople: people, + expected: `Email me at john@example.com
`, + }, + { + name: "replaces correct occurrence by position", + content: `path/@John then @John
`, + mockPeople: people, + expected: `path/@John then