diff --git a/.surface b/.surface index e42ccfe3..44464090 100644 --- a/.surface +++ b/.surface @@ -45,6 +45,11 @@ hey bulk-reply send --attach hey bulk-reply send --message hey bulk-reply undo hey calendars +hey clip +hey clip create +hey clip create --content +hey clip delete +hey clips hey collection hey collection --all hey collection --limit diff --git a/README.md b/README.md index 08758ac4..82306ba8 100644 --- a/README.md +++ b/README.md @@ -263,10 +263,11 @@ Listing commands also answer `--markdown` for a table, `--styled` to force the h rendering when the output is piped, `--ids-only` for one ID per line, and `--count` for a bare number. `--ids-only` and `--count` need list data, so they work on `hey boxes`, `hey box`, `hey labels`, `hey label`, `hey collections`, `hey collection`, `hey workflows`, -`hey workflow`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, +`hey workflow`, `hey clips`, `hey snippets`, `hey drafts`, `hey search`, `hey contacts list`, `hey screener list`, `hey screener history`, `hey calendars`, `hey recordings`, `hey todo list`, `hey timetrack list` and `hey journal list`. The data-only formats print any pagination notice on stderr, so the IDs on stdout stay -pipeable. +pipeable. `hey clips --ids-only` and `--count` cover the newest page only because the +released SDK does not expose HEY's cursor for older clip pages. `--html` writes the original HTML, for the commands that hold some: `hey threads`, `hey journal read`, `hey contacts show` and `hey contacts note show`. It is a format of @@ -317,7 +318,10 @@ hey workflow stage update 654 321 --name "Interviewing" hey workflow add 987 --to 654 --stage 321 # add a topic ID to a stage hey workflow move 987 --workflow 654 --to 322 # move it to another stage hey workflow remove 987 --from 654 # remove it from the workflow -hey snippets # list reusable email snippets +hey clips # newest page of saved passages and source context +hey clip create 456 --content "The launch moves to Wednesday." +hey clip delete 44 +hey snippets # list reusable email snippets hey snippet create --name "Scheduling reply" --content "Tuesday works for me." hey snippet update 44 --content "Wednesday works for me." hey snippet delete 44 @@ -392,6 +396,8 @@ Collection IDs come from `hey collections`. `hey collection` returns both each p Workflow IDs come from `hey workflows`, which includes the linked account ID for each workflow. `hey workflow ` returns stages in position order; `--ids-only` and `--count` apply to those stages. Creating a workflow needs one linked mail account, selected with `--account` when more than one is available. HEY creates new stages as `Untitled`, so create the stage, read its ID with `hey workflow `, then rename it. Workflow membership commands take `topic_id`. Adding a thread creates its workflow membership before selecting the requested stage; if stage selection fails, the thread remains in the workflow's first stage and the command reports the error. +Clips are passages saved from existing email entries. `hey clips` lists the selected account's newest page with each clip's source entry and thread context; its JSON `notice` and the data-only formats' stderr make that boundary explicit because the released SDK does not expose HEY's cursor for older pages. `hey clip create --content ` verifies that the passage is source-backed by text carried in the entry, including embedded inbound email bodies. It accepts whitespace differences while preserving the supplied text exactly for HEY's web UI; passages are capped at 64 KiB and source-message validation at 1 MiB. HEY's web UI remains authoritative for stylesheet-driven visibility. HEY assigns a created clip to its source entry's account and resolves deletion by identity-owned clip ID across linked accounts; `--account` selects list presentation. `hey clip delete ` removes it. Clip content is plain text; the source entry ID comes from `hey threads --json`. + Snippets are named reusable email content, separate from clips saved out of received messages. `hey snippets` lists both plain text and HEY's rich-text HTML; `hey snippet create`, `update`, and `delete` manage them. A create requires a non-empty name and content. Updates change whichever non-empty fields are supplied, while omitted fields stay as they are. In the TUI, Ctrl+T opens the picker from new-message, reply, and forward forms and inserts the snippet's plain-text representation at the current body cursor without replacing the draft. `hey box `, `hey label ` and `hey collection ` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`. diff --git a/internal/cmd/clip.go b/internal/cmd/clip.go new file mode 100644 index 00000000..93fb1c3d --- /dev/null +++ b/internal/cmd/clip.go @@ -0,0 +1,268 @@ +package cmd + +import ( + "fmt" + "strings" + "unicode" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +type clipsCommand struct { + cmd *cobra.Command +} + +func newClipsCommand() *clipsCommand { + clipsCommand := &clipsCommand{} + clipsCommand.cmd = &cobra.Command{ + Use: "clips", + Short: "List the newest page of passages clipped from email", + Annotations: map[string]string{ + "agent_notes": "Returns the newest page of clip IDs, content, source entry IDs, and source thread context. The SDK does not expose the cursor for older pages. Use an ID with hey clip delete.", + }, + Example: ` hey clips + hey clips --json + hey clips --ids-only`, + RunE: clipsCommand.run, + Args: cobra.NoArgs, + } + return clipsCommand +} + +func (c *clipsCommand) run(cmd *cobra.Command, _ []string) error { + if err := requireAuth(); err != nil { + return err + } + + clips, err := sdk.Clips().List(cmd.Context()) + if err != nil { + return apierr.FromSDK(err) + } + notice := clipsPageNotice(clips) + if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } + + switch writer.EffectiveFormat() { + case output.FormatStyled: + if len(clips) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No clips found") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Content", "Entry", "Thread", "Saved"}) + for _, clip := range clips { + table.addRow([]string{ + fmt.Sprintf("%d", clip.Id), + truncate(terminal.SanitizeLine(clip.Content), 60), + fmt.Sprintf("%d", clip.EntryId), + clipTopicLabel(clip.Topic), + formatDate(clip.CreatedAt), + }) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), "Notice: "+notice) + } + return nil + case output.FormatMarkdown: + return writeClipsMarkdown(cmd, clips) + default: + return writeOK(clips, + output.WithSummary(fmt.Sprintf("%d %s", len(clips), clipNoun(len(clips)))), + output.WithNotice(notice), + output.WithBreadcrumbs( + output.Breadcrumb{Action: "create", Command: "hey clip create --content ", Description: "Save text from an email entry"}, + output.Breadcrumb{Action: "delete", Command: "hey clip delete ", Description: "Delete a clip"}, + ), + ) + } +} + +func clipsPageNotice(clips []generated.Clip) string { + if len(clips) == 0 { + return "" + } + return "Showing HEY's newest clips page. The SDK does not expose the cursor for older pages." +} + +func clipTopicLabel(topic generated.ClipTopic) string { + name := terminal.SanitizeLine(topic.Name) + if name == "" { + return fmt.Sprintf("%d", topic.Id) + } + return fmt.Sprintf("%s (%d)", name, topic.Id) +} + +func writeClipsMarkdown(cmd *cobra.Command, clips []generated.Clip) error { + if len(clips) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "(no results)") + return err + } + var document strings.Builder + document.WriteString("| id | content | entry_id | topic_id | topic | saved |\n") + document.WriteString("| --- | --- | --- | --- | --- | --- |\n") + for _, clip := range clips { + fmt.Fprintf(&document, "| %d | %s | %d | %d | %s | %s |\n", + clip.Id, + markdownSafeText(clip.Content), + clip.EntryId, + clip.Topic.Id, + markdownSafeText(clip.Topic.Name), + formatDate(clip.CreatedAt), + ) + } + _, err := fmt.Fprint(cmd.OutOrStdout(), document.String()) + return err +} + +func clipNoun(count int) string { + if count == 1 { + return "clip" + } + return "clips" +} + +type clipCommand struct { + cmd *cobra.Command +} + +func newClipCommand() *clipCommand { + clipCommand := &clipCommand{} + clipCommand.cmd = &cobra.Command{ + Use: "clip", + Short: "Save and manage passages from email", + Annotations: map[string]string{ + "agent_notes": "Create a clip from text carried by an email entry, or delete a clip. HEY assigns a created clip to the source entry's account and resolves deletion by identity-owned clip ID across linked accounts; --account selects list presentation. The CLI verifies that the passage is source-backed by the entry's message content before saving it, with a 64 KiB passage limit and a 1 MiB source-validation limit. Find clip IDs with hey clips.", + }, + } + clipCommand.cmd.AddCommand(newClipCreateCommand().cmd) + clipCommand.cmd.AddCommand(newClipDeleteCommand().cmd) + return clipCommand +} + +const ( + maxClipContentBytes = 64 << 10 + maxClipSourceBytes = 1 << 20 +) + +type clipCreateCommand struct { + cmd *cobra.Command + content string +} + +func newClipCreateCommand() *clipCreateCommand { + createCommand := &clipCreateCommand{} + createCommand.cmd = &cobra.Command{ + Use: "create ", + Aliases: []string{"add"}, + Short: "Save text from an email entry", + Long: "Save a passage from an email entry. HEY assigns the clip to the source entry's account. The content must be present in the entry's message text; whitespace differences are accepted. Passages are limited to 64 KiB and source entries to 1 MiB for validation.", + Example: ` hey clip create 987 --content "The launch moves to Wednesday."`, + RunE: createCommand.run, + Args: usageExactOneArg(), + } + createCommand.cmd.Flags().StringVar(&createCommand.content, "content", "", "Text selected from the email entry (required)") + return createCommand +} + +func (c *clipCreateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + entryID, err := parsePositiveID(args[0], "entry") + if err != nil { + return err + } + if strings.TrimSpace(c.content) == "" { + return apierr.ErrUsage("--content is required") + } + if len(c.content) > maxClipContentBytes { + return apierr.ErrUsage(fmt.Sprintf("--content exceeds the %d KiB clip limit", maxClipContentBytes>>10)) + } + message, err := sdk.Messages().Get(cmd.Context(), entryID) + if err != nil { + return apierr.FromSDK(err) + } + if message == nil { + return apierr.ErrNotFound("message", fmt.Sprintf("%d", entryID)) + } + if len(message.Content) > maxClipSourceBytes { + return apierr.ErrAPI(0, fmt.Sprintf("entry %d content exceeds the %d MiB clip validation limit", entryID, maxClipSourceBytes>>20)) + } + if !clipContentMatches(c.content, message.Content) { + return apierr.ErrUsageHint( + fmt.Sprintf("--content does not match text in entry %d", entryID), + "Copy an exact passage from the entry; whitespace differences are allowed.", + ) + } + if err := sdk.Clips().Create(cmd.Context(), entryID, c.content); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Clip from entry %d created", entryID), map[string]any{"entry_id": entryID}, + output.WithBreadcrumbs(output.Breadcrumb{Action: "list", Command: "hey clips", Description: "Find the new clip ID"}), + ) +} + +func clipContentMatches(content, entryHTML string) bool { + selected := normalizeClipText(content) + entry := normalizeClipText(htmlutil.MessageSourceText(entryHTML)) + return selected != "" && strings.Contains(entry, selected) +} + +func normalizeClipText(text string) string { + var normalized strings.Builder + normalized.Grow(len(text)) + pendingSpace := false + for _, r := range text { + if unicode.IsSpace(r) { + pendingSpace = normalized.Len() > 0 + continue + } + if pendingSpace { + normalized.WriteByte(' ') + pendingSpace = false + } + normalized.WriteRune(r) + } + return normalized.String() +} + +type clipDeleteCommand struct { + cmd *cobra.Command +} + +func newClipDeleteCommand() *clipDeleteCommand { + deleteCommand := &clipDeleteCommand{} + deleteCommand.cmd = &cobra.Command{ + Use: "delete ", + Aliases: []string{"remove", "rm"}, + Short: "Delete a saved clip", + Long: "Delete an identity-owned clip by ID across linked accounts.", + Example: ` hey clip delete 44`, + RunE: deleteCommand.run, + Args: usageExactOneArg(), + } + return deleteCommand +} + +func (c *clipDeleteCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + clipID, err := parsePositiveID(args[0], "clip") + if err != nil { + return err + } + if err := sdk.Clips().Delete(cmd.Context(), clipID); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Clip %d deleted", clipID), map[string]any{"id": clipID}) +} diff --git a/internal/cmd/clip_test.go b/internal/cmd/clip_test.go new file mode 100644 index 00000000..14830c65 --- /dev/null +++ b/internal/cmd/clip_test.go @@ -0,0 +1,369 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +const clipsJSON = `[ + {"id":4,"content":"The launch moves to Wednesday.","entry_id":987,"topic":{"id":55,"name":"Launch planning","app_url":"https://app.hey.com/topics/55"},"created_at":"2026-08-22T02:03:04Z"}, + {"id":3,"content":"Keep the existing rollout window.","entry_id":876,"topic":{"id":44,"name":"Release notes","app_url":"https://app.hey.com/topics/44"},"created_at":"2026-08-21T01:02:03Z"} +]` + +func TestClipsCommandListsClipsInEveryFormat(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/clips.json" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, clipsJSON) + }) + + response, err := runJSONCommand(t, handler, "clips") + if err != nil { + t.Fatal(err) + } + if response.Summary != "2 clips" || !strings.Contains(response.Notice, "newest clips page") { + t.Errorf("summary = %q, notice = %q", response.Summary, response.Notice) + } + items := response.Data.([]any) + first := items[0].(map[string]any) + topic := first["topic"].(map[string]any) + if len(items) != 2 || first["entry_id"] != float64(987) || topic["app_url"] == "" { + t.Errorf("items = %#v", items) + } + + ids, idsStderr, err := runFormattedCommandWithStderr(t, handler, []string{"--ids-only"}, "clips") + if err != nil || ids != "4\n3\n" || !strings.Contains(idsStderr, "newest clips page") { + t.Errorf("ids = %q, stderr = %q, err = %v", ids, idsStderr, err) + } + count, err := runFormattedCommand(t, handler, []string{"--count"}, "clips") + if err != nil || count != "2\n" { + t.Errorf("count = %q, err = %v", count, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") + if err != nil || !strings.Contains(markdown, "| 4 | The launch moves to Wednesday\\. | 987 | 55 | Launch planning |") { + t.Errorf("markdown = %q, err = %v", markdown, err) + } + styled, styledStderr, err := runFormattedCommandWithStderr(t, handler, []string{"--styled"}, "clips") + if err != nil || !strings.Contains(styled, "Launch planning (55)") || !strings.Contains(styled, "Entry") || !strings.Contains(styledStderr, "newest clips page") { + t.Errorf("styled = %q, stderr = %q, err = %v", styled, styledStderr, err) + } +} + +func TestClipsMarkdownSurfacesWriteFailure(t *testing.T) { + cmd := newClipsCommand().cmd + cmd.SetOut(failingWriter{}) + if err := writeClipsMarkdown(cmd, []generated.Clip{{Id: 4, Content: "Keep this", EntryId: 987}}); err == nil { + t.Fatal("expected the write failure") + } +} + +func TestClipsCommandPreservesEmptyList(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + }) + response, err := runJSONCommand(t, handler, "clips") + if err != nil { + t.Fatal(err) + } + if items := response.Data.([]any); len(items) != 0 || response.Summary != "0 clips" || response.Notice != "" { + t.Errorf("response = %#v", response) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") + if err != nil || markdown != "(no results)\n" { + t.Errorf("markdown = %q, err = %v", markdown, err) + } + styled, err := runStyledCommand(t, handler, "clips") + if err != nil || styled != "No clips found\n" { + t.Errorf("styled = %q, err = %v", styled, err) + } +} + +func TestClipsCommandSanitizesHumanOutput(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[{"id":4,"content":"[click](https://example.invalid)\u001b[31m","entry_id":987,"topic":{"id":55,"name":"Safe\u001b[31mRed"}}]`) + }) + styled, err := runStyledCommand(t, handler, "clips") + if err != nil || strings.Contains(styled, "\x1b[31m") { + t.Errorf("styled = %q, err = %v", styled, err) + } + markdown, err := runFormattedCommand(t, handler, []string{"--markdown"}, "clips") + if err != nil || strings.Contains(markdown, "[click](") || !strings.Contains(markdown, `\[click\]`) { + t.Errorf("markdown = %q, err = %v", markdown, err) + } +} + +func TestClipCreateSendsEntryAndExactContent(t *testing.T) { + content := " The launch moves to Wednesday.\nPlease tell the team. " + handler := clipCreateHandler(t, 987, `

The launch moves to Wednesday.

Please tell the team.

`, func(r *http.Request) { + if got := r.PostForm.Get("clip[entry_id]"); got != "987" { + t.Errorf("entry_id = %q", got) + } + if got := r.PostForm.Get("clip[content]"); got != content { + t.Errorf("content = %q", got) + } + }) + response, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", content) + if err != nil { + t.Fatal(err) + } + if response.Summary != "Clip from entry 987 created" || response.Data.(map[string]any)["entry_id"] != float64(987) { + t.Errorf("response = %#v", response) + } +} + +func TestClipCreateMatchesBrowserSelectionText(t *testing.T) { + tests := []struct { + name string + source string + content string + }{ + { + name: "rich text and whitespace", + source: `

The launch moves to
Wednesday & Thursday.

`, + content: "The launch moves\r\n\tto Wednesday & Thursday.", + }, + { + name: "adjacent inline elements", + source: `

quarterly rollout

`, + content: "quarterly rollout", + }, + { + name: "selection across list items", + source: `
  • Revenue up
  • Churn down
`, + content: "Revenue up\nChurn down", + }, + { + name: "unicode line separator", + source: "

First\u2028Second

", + content: "First Second", + }, + { + name: "embedded inbound email body", + source: `
`, + content: "External confirmation: BLUE-42", + }, + { + name: "literal HTML-shaped text", + source: `

Keep <strong>literal</strong> text.

`, + content: `literal`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := clipCreateHandler(t, 987, tt.source, func(r *http.Request) { + if got := r.PostForm.Get("clip[content]"); got != tt.content { + t.Errorf("stored content = %q, want exact input %q", got, tt.content) + } + }) + if _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", tt.content); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestClipCreateRejectsTextOutsideTheEntry(t *testing.T) { + tests := []struct { + name string + source string + content string + }{ + {name: "unrelated text", source: `

The launch moves to Wednesday.

`, content: "An unrelated reminder"}, + {name: "case differs", source: `

Confirmation Code: BLUE-42

`, content: "confirmation code: BLUE-42"}, + {name: "markup is not selected text", source: `

The launch moved.

`, content: `launch`}, + {name: "script text is not selectable", source: `

Visible text

`, content: "secret value"}, + {name: "hidden text is not selectable", source: `

Visible text

`, content: "Hidden preheader"}, + {name: "block boundaries remain distinct", source: `
Alpha
Beta
`, content: "AlphaBeta"}, + {name: "unparseable source fails closed", source: strings.Repeat("
", 1_000) + "not selectable" + strings.Repeat("
", 1_000), content: "not selectable"}, + {name: "summary is not entry content", source: ``, content: "Preview summary"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet || r.URL.Path != "/messages/987.json" { + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected mutation", http.StatusInternalServerError) + return + } + writeClipSource(t, w, 987, tt.source, "Preview summary") + }) + _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", tt.content) + if err == nil || !strings.Contains(err.Error(), "--content does not match text in entry 987") { + t.Fatalf("error = %v", err) + } + if requests != 1 { + t.Errorf("requests = %d, want the source read without a clip mutation", requests) + } + }) + } +} + +func TestClipCreateRequiresAnAvailableSourceMessage(t *testing.T) { + t.Run("read failure", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/messages/987.json" { + t.Fatalf("unexpected request = %s %s", r.Method, r.URL.Path) + } + http.NotFound(w, r) + }) + if _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", "Keep this"); err == nil { + t.Fatal("expected the source read failure") + } + }) + + t.Run("empty response", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `null`) + }) + _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", "Keep this") + if err == nil || !strings.Contains(err.Error(), "--content does not match text in entry 987") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("source over validation limit", func(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/messages/987.json" { + t.Fatalf("unexpected request = %s %s", r.Method, r.URL.Path) + } + writeClipSource(t, w, 987, strings.Repeat("x", maxClipSourceBytes+1), "") + }) + _, err := runJSONCommand(t, handler, "clip", "create", "987", "--content", "x") + if err == nil || !strings.Contains(err.Error(), "content exceeds the 1 MiB clip validation limit") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestClipDeleteUsesClipID(t *testing.T) { + response, err := runJSONCommand(t, clipMutationHandler(t, http.MethodDelete, "/clips/44", nil), "clip", "delete", "44") + if err != nil { + t.Fatal(err) + } + if response.Summary != "Clip 44 deleted" || response.Data.(map[string]any)["id"] != float64(44) { + t.Errorf("response = %#v", response) + } +} + +func TestClipCommandsForwardTheSelectedAccountContext(t *testing.T) { + var requested []string + server := linkedAccountServer(t, func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.Method+" "+r.URL.Path+" account="+r.URL.Query().Get("filtered_account_id")) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/clips.json": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `[]`) + case r.Method == http.MethodGet && r.URL.Path == "/messages/987.json": + writeClipSource(t, w, 987, "Keep this", "") + case r.Method == http.MethodPost && r.URL.Path == "/clips": + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + case r.Method == http.MethodDelete && r.URL.Path == "/clips/44": + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + default: + http.NotFound(w, r) + } + }) + + for _, args := range [][]string{ + {"--account", "2", "clips"}, + {"--account", "2", "clip", "create", "987", "--content", "Keep this"}, + {"--account", "2", "clip", "delete", "44"}, + } { + if output, err := runAccountsCLI(t, server, args...); err != nil { + t.Fatalf("hey %s: %v\n%s", strings.Join(args, " "), err, output) + } + } + if got := strings.Join(requested, "\n"); got != "GET /clips.json account=2\nGET /messages/987.json account=2\nPOST /clips account=2\nDELETE /clips/44 account=2" { + t.Errorf("requests:\n%s", got) + } +} + +func TestClipCommandsValidateInput(t *testing.T) { + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("unexpected request") + }) + tests := []struct { + name string + args []string + want string + }{ + {name: "create content", args: []string{"clip", "create", "987"}, want: "--content is required"}, + {name: "blank content", args: []string{"clip", "create", "987", "--content", " "}, want: "--content is required"}, + {name: "oversized content", args: []string{"clip", "create", "987", "--content", strings.Repeat("x", maxClipContentBytes+1)}, want: "--content exceeds the 64 KiB clip limit"}, + {name: "invalid entry", args: []string{"clip", "create", "zero", "--content", "Keep this"}, want: "invalid entry ID: zero"}, + {name: "invalid clip", args: []string{"clip", "delete", "zero"}, want: "invalid clip ID: zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runJSONCommand(t, handler, tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} + +func clipCreateHandler(t *testing.T, entryID int64, source string, validate func(*http.Request)) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/messages/987.json": + writeClipSource(t, w, entryID, source, "") + case r.Method == http.MethodPost && r.URL.Path == "/clips": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if validate != nil { + validate(r) + } + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) +} + +func writeClipSource(t *testing.T, w http.ResponseWriter, entryID int64, content, summary string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{"id": entryID, "content": content, "summary": summary}); err != nil { + t.Fatal(err) + } +} + +func clipMutationHandler(t *testing.T, method, path string, validate func(*http.Request)) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method || r.URL.Path != path { + t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, method, path) + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if validate != nil { + validate(r) + } + w.Header().Set("Location", "/clips") + w.WriteHeader(http.StatusFound) + }) +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go index bdb8014b..c5d45da1 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -21,7 +21,7 @@ var curatedCategories = []struct { }, { heading: "EMAIL", - names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "snippets", "snippet", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, + names: []string{"boxes", "box", "labels", "label", "collections", "collection", "workflows", "workflow", "clips", "clip", "snippets", "snippet", "search", "contacts", "screener", "threads", "share", "unshare", "attachments", "compose", "reply", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 79136fc2..f00fb408 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -32,7 +32,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) { func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { root := newRootCmd() - for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "snippets", "snippet", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { + for _, name := range []string{"boxes", "box", "labels", "label", "workflows", "workflow", "clips", "clip", "snippets", "snippet", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring", "watch"} { t.Run(name, func(t *testing.T) { command, _, err := root.Find([]string{name}) if err != nil { @@ -105,6 +105,8 @@ EMAIL collection View and manage an email collection workflows List your email workflows workflow View and manage an email workflow + clips List the newest page of passages clipped from email + clip Save and manage passages from email snippets List reusable email snippets snippet Create and manage reusable email snippets search Search email threads and messages diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a0b16078..7983e97f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -189,6 +189,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newCollectionCommand().cmd) root.AddCommand(newWorkflowsCommand().cmd) root.AddCommand(newWorkflowCommand().cmd) + root.AddCommand(newClipsCommand().cmd) + root.AddCommand(newClipCommand().cmd) root.AddCommand(newSnippetsCommand().cmd) root.AddCommand(newSnippetCommand().cmd) root.AddCommand(newSearchCommand().cmd) diff --git a/internal/htmlutil/htmlutil.go b/internal/htmlutil/htmlutil.go index 8e99414c..ccfe248d 100644 --- a/internal/htmlutil/htmlutil.go +++ b/internal/htmlutil/htmlutil.go @@ -26,6 +26,17 @@ func ToText(s string) string { return strings.TrimSpace(result) } +// MessageSourceText returns the source-backed text carried by HTML message content. +func MessageSourceText(s string) string { + doc, err := html.Parse(strings.NewReader(s)) + if err != nil { + return "" + } + var b strings.Builder + walkMessageSourceNode(&b, doc, 0) + return strings.TrimSpace(b.String()) +} + // PrependText adds a plain-text note before existing HTML content. func PrependText(content, note string) string { note = strings.TrimSpace(strings.ReplaceAll(note, "\r\n", "\n")) @@ -139,6 +150,142 @@ type trixAttachment struct { Content string `json:"content"` } +func walkMessageSourceNode(b *strings.Builder, n *html.Node, depth int) { + switch n.Type { //nolint:exhaustive // only text and element nodes need handling + case html.TextNode: + b.WriteString(n.Data) + case html.ElementNode: + if !elementProvidesMessageSourceText(n) { + return + } + switch n.Data { + case "br": + b.WriteByte('\n') + return + case "hr": + writeMessageSourceBoundary(b) + return + case "template": + if depth == 0 || n.Parent == nil || n.Parent.Data != "shadow-content" { + return + } + case "details": + if !hasAttr(n, "open") { + walkClosedDetailsSummary(b, n, depth) + return + } + case "figure": + if att := parseTrixAttachment(n); att != nil && att.Filename == "" && att.Content != "" { + if doc := parseEmbeddedContent(att.Content, depth); doc != nil { + writeMessageSourceBoundary(b) + walkMessageSourceNode(b, doc, depth+1) + writeMessageSourceBoundary(b) + return + } + } + } + if messageBlockElement(n) { + writeMessageSourceBoundary(b) + walkMessageSourceChildren(b, n, depth) + writeMessageSourceBoundary(b) + return + } + } + walkMessageSourceChildren(b, n, depth) +} + +func elementProvidesMessageSourceText(n *html.Node) bool { + if hasAttr(n, "hidden") || hasAttr(n, "inert") || inlineStyleHidesText(getAttr(n, "style")) { + return false + } + switch n.Data { + case "script", "style", "noscript", "head", "action-text-attachment": + return false + case "dialog": + return hasAttr(n, "open") + default: + return true + } +} + +func inlineStyleHidesText(style string) bool { + display, _ := inlineStyleProperty(style, "display") + visibility, _ := inlineStyleProperty(style, "visibility") + contentVisibility, _ := inlineStyleProperty(style, "content-visibility") + userSelect, _ := inlineStyleProperty(style, "user-select", "-webkit-user-select") + return display == "none" || visibility == "hidden" || visibility == "collapse" || + contentVisibility == "hidden" || userSelect == "none" +} + +func inlineStyleProperty(style string, names ...string) (string, bool) { + wanted := make(map[string]bool, len(names)) + for _, name := range names { + wanted[name] = true + } + var selected string + selectedImportant := false + found := false + for declaration := range strings.SplitSeq(strings.ToLower(style), ";") { + property, value, ok := strings.Cut(declaration, ":") + if !ok || !wanted[strings.TrimSpace(property)] { + continue + } + value = strings.TrimSpace(value) + important := strings.HasSuffix(value, "!important") + if important { + value = strings.TrimSpace(strings.TrimSuffix(value, "!important")) + } + if !found || important || !selectedImportant { + selected = value + selectedImportant = important + found = true + } + } + return selected, found +} + +func messageBlockElement(n *html.Node) bool { + if display, ok := inlineStyleProperty(getAttr(n, "style"), "display"); ok { + kind := display + if fields := strings.Fields(display); len(fields) > 0 { + kind = fields[0] + } + switch kind { + case "inline", "inline-block", "inline-flex", "inline-grid", "inline-table", "contents": + return false + case "block", "flow-root", "flex", "grid", "list-item", "table", "table-caption", "table-cell", "table-footer-group", "table-header-group", "table-row", "table-row-group": + return true + } + } + switch n.Data { + case "address", "article", "aside", "blockquote", "caption", "dd", "details", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "li", "main", "menu", "nav", "ol", "p", "pre", "section", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul": + return true + default: + return false + } +} + +func walkClosedDetailsSummary(b *strings.Builder, details *html.Node, depth int) { + for child := details.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == "summary" { + walkMessageSourceNode(b, child, depth) + return + } + } +} + +func walkMessageSourceChildren(b *strings.Builder, n *html.Node, depth int) { + for child := n.FirstChild; child != nil; child = child.NextSibling { + walkMessageSourceNode(b, child, depth) + } +} + +func writeMessageSourceBoundary(b *strings.Builder) { + if b.Len() > 0 { + b.WriteByte('\n') + } +} + func parseTrixAttachment(n *html.Node) *trixAttachment { raw := getAttr(n, "data-trix-attachment") if raw == "" { @@ -160,6 +307,15 @@ func getAttr(n *html.Node, key string) string { return "" } +func hasAttr(n *html.Node, key string) bool { + for _, attribute := range n.Attr { + if attribute.Key == key { + return true + } + } + return false +} + func walkChildren(b *strings.Builder, n *html.Node, depth int) { for c := n.FirstChild; c != nil; c = c.NextSibling { walkNode(b, c, depth) diff --git a/internal/htmlutil/htmlutil_test.go b/internal/htmlutil/htmlutil_test.go index 5c1b3f22..e0ad085f 100644 --- a/internal/htmlutil/htmlutil_test.go +++ b/internal/htmlutil/htmlutil_test.go @@ -57,6 +57,77 @@ func TestToTextEmpty(t *testing.T) { } } +func TestMessageSourceTextMatchesBrowserSelectionContent(t *testing.T) { + tests := []struct { + name string + html string + want string + }{ + {name: "inline formatting", html: `

quarterly plan

`, want: "quarterly plan"}, + {name: "blocks and list items", html: `

First line

  • Revenue up
  • Churn down
`, want: "First line Revenue up Churn down"}, + {name: "section boundaries", html: `
Alpha
Beta
`, want: "Alpha Beta"}, + {name: "computed block boundaries", html: `AlphaBeta`, want: "Alpha Beta"}, + {name: "computed inline flow", html: `
Alpha
Beta
`, want: "AlphaBeta"}, + {name: "entities", html: `

R&D uses <draft> today

`, want: "R&D uses today"}, + {name: "line break", html: `

First
Second

`, want: "First Second"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := strings.Join(strings.Fields(MessageSourceText(tt.html)), " "); got != tt.want { + t.Errorf("MessageSourceText = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMessageSourceTextIncludesEmbeddedEmailBody(t *testing.T) { + html := `
` + if got := strings.Join(strings.Fields(MessageSourceText(html)), " "); got != "External confirmation: BLUE-42" { + t.Errorf("MessageSourceText = %q", got) + } +} + +func TestMessageSourceTextFailsClosedWhenHTMLExceedsParserDepth(t *testing.T) { + html := strings.Repeat("
", 1_000) + "not selectable" + strings.Repeat("
", 1_000) + if got := MessageSourceText(html); got != "" { + t.Errorf("MessageSourceText returned unparsed source bytes: %q", got[:min(len(got), 80)]) + } +} + +func TestMessageSourceTextExcludesNonselectableContent(t *testing.T) { + html := `

Visible

attachment internals` + if got := strings.Join(strings.Fields(MessageSourceText(html)), " "); got != "Visible" { + t.Errorf("MessageSourceText = %q, want visible message text only", got) + } +} + +func TestMessageSourceTextHonorsHTMLVisibility(t *testing.T) { + tests := []struct { + name string + html string + want string + }{ + {name: "hidden attribute", html: `

Before

After

`, want: "Before After"}, + {name: "inert subtree", html: `

Before

Inactive

After

`, want: "Before After"}, + {name: "display none", html: `

Before

Hidden

After

`, want: "Before After"}, + {name: "later display wins", html: `

Visible

`, want: "Visible"}, + {name: "important display wins", html: `

Hidden

`, want: ""}, + {name: "visibility hidden", html: `

Before

Hidden

After

`, want: "Before After"}, + {name: "selection disabled", html: `

Before

Hidden

After

`, want: "Before After"}, + {name: "ordinary template", html: `

Before

After

`, want: "Before After"}, + {name: "closed dialog", html: `

Before

Closed dialog

After

`, want: "Before After"}, + {name: "closed details", html: `
Visible summary

Closed content

`, want: "Visible summary"}, + {name: "open details and dialog", html: `
Summary

Details

Dialog`, want: "Summary Details Dialog"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := strings.Join(strings.Fields(MessageSourceText(tt.html)), " "); got != tt.want { + t.Errorf("MessageSourceText = %q, want %q", got, tt.want) + } + }) + } +} + func TestToTextImgTag(t *testing.T) { got := ToText(`

Before

photo

After

`) if !strings.Contains(got, "[photo]") { diff --git a/tests/smoke/clips_test.go b/tests/smoke/clips_test.go new file mode 100644 index 00000000..bb1906f4 --- /dev/null +++ b/tests/smoke/clips_test.go @@ -0,0 +1,111 @@ +package smoke_test + +import ( + "strconv" + "strings" + "testing" +) + +type smokeClip struct { + ID int64 `json:"id"` + Content string `json:"content"` + EntryID int64 `json:"entry_id"` + Topic struct { + ID int64 `json:"id"` + Name string `json:"name"` + AppURL string `json:"app_url"` + } `json:"topic"` +} + +func TestClipLifecycle(t *testing.T) { + topicID, subject := longThread(t, 0) + entries := dataAs[[]threadEntry](t, heyJSON(t, "threads", topicID)) + if len(entries) == 0 || entries[0].ID == 0 { + t.Fatalf("thread %s has no source entry", topicID) + } + entryID := strconv.FormatInt(entries[0].ID, 10) + content := "**quarterly** numbers are at https://example.com/reports/q3" + + _, stderr := heyFail(t, "clip", "create", entryID, "--content", "Text that is not present in the source entry", "--json") + if !strings.Contains(stderr, "does not match text in entry") { + t.Fatalf("unmatched clip error = %q", stderr) + } + + _, stderr, code := hey(t, "clip", "create", entryID, "--content", content, "--json") + if code != 0 { + skipf(t, "clip create unavailable (exit %d): %s", code, stderr) + } + + deleted := false + t.Cleanup(func() { + if deleted { + return + } + for _, listed := range listClips(t) { + if listed.EntryID == entries[0].ID { + _, _, _ = hey(t, "clip", "delete", strconv.FormatInt(listed.ID, 10)) + } + } + }) + + clip := findClipByEntry(t, entries[0].ID) + if clip.ID == 0 || clip.Content != content || strconv.FormatInt(clip.Topic.ID, 10) != topicID || clip.Topic.Name != subject || clip.Topic.AppURL == "" { + t.Fatalf("created clip = %+v", clip) + } + clipID := strconv.FormatInt(clip.ID, 10) + + page := browserPageText(t, baseURL+"/clips") + if !strings.Contains(page, content) || !strings.Contains(page, subject) { + t.Errorf("browser clips page does not show the exact clip text and source thread") + } + html := fetchHTML(t, baseURL+"/clips") + if strings.Contains(html, "quarterly") { + t.Errorf("browser clips page interpreted plain clip content as HTML") + } + + _, stderr, code = hey(t, "clip", "delete", clipID, "--json") + if code != 0 { + t.Fatalf("clip delete failed (exit %d): %s", code, stderr) + } + deleted = true + for _, listed := range listClips(t) { + if listed.ID == clip.ID { + t.Fatalf("deleted clip %d is still listed", clip.ID) + } + } +} + +func TestClipOutputFormatsAndValidation(t *testing.T) { + for _, args := range [][]string{ + {"clips", "--quiet"}, + {"clips", "--ids-only"}, + {"clips", "--count"}, + {"clips", "--markdown"}, + {"clips", "--styled"}, + } { + _, stderr, code := hey(t, args...) + if code != 0 { + t.Errorf("hey %s failed (exit %d): %s", strings.Join(args, " "), code, stderr) + } + } + + heyFail(t, "clip", "create", "987") + heyFail(t, "clip", "create", "not-an-id", "--content", "Keep this") + heyFail(t, "clip", "delete", "not-an-id") +} + +func listClips(t *testing.T) []smokeClip { + t.Helper() + return dataAs[[]smokeClip](t, heyJSON(t, "clips")) +} + +func findClipByEntry(t *testing.T, entryID int64) smokeClip { + t.Helper() + for _, clip := range listClips(t) { + if clip.EntryID == entryID { + return clip + } + } + t.Fatalf("clip from entry %d not found", entryID) + return smokeClip{} +}