From d7363a6fc22251bd42356b805c51307c9b1d767b Mon Sep 17 00:00:00 2001 From: Silvia Burgos Date: Fri, 24 Jul 2026 15:05:15 -0600 Subject: [PATCH] Add `collections` and `collection` commands `hey collections` lists all collections via GET /collections.json. `hey collection ` lists every thread in a collection. The collection detail view has no JSON API and renders only ~50 threads per page, so the command walks the full timeline via the "next page" pagination link and parses both card__link (recent) and entry__collection-topic (older) anchors, de-duplicating across pages. Uses the existing OAuth auth; no session cookie required. Includes unit tests for the HTML parser and next-page extraction, and regenerates the .surface baseline for the new commands. Co-Authored-By: Claude Opus 4.8 (1M context) --- .surface | 4 + internal/cmd/collection.go | 105 ++++++++++++++++++++++++++ internal/cmd/collections.go | 89 ++++++++++++++++++++++ internal/cmd/root.go | 2 + internal/htmlutil/collections.go | 72 ++++++++++++++++++ internal/htmlutil/collections_test.go | 43 +++++++++++ 6 files changed, 315 insertions(+) create mode 100644 internal/cmd/collection.go create mode 100644 internal/cmd/collections.go create mode 100644 internal/htmlutil/collections.go create mode 100644 internal/htmlutil/collections_test.go diff --git a/.surface b/.surface index c688616..8b02e69 100644 --- a/.surface +++ b/.surface @@ -27,6 +27,10 @@ hey boxes hey boxes --all hey boxes --limit hey calendars +hey collection +hey collections +hey collections --all +hey collections --limit hey commands hey completion hey compose diff --git a/internal/cmd/collection.go b/internal/cmd/collection.go new file mode 100644 index 0000000..ecdbeff --- /dev/null +++ b/internal/cmd/collection.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/output" +) + +// maxCollectionPages bounds the timeline walk in case pagination never +// terminates. A collection's threads plus interleaved calendar events span at +// most a few dozen pages in practice; this is a safety backstop. +const maxCollectionPages = 200 + +type collectionCommand struct { + cmd *cobra.Command +} + +func newCollectionCommand() *collectionCommand { + collectionCommand := &collectionCommand{} + collectionCommand.cmd = &cobra.Command{ + Use: "collection ", + Short: "List topics in a collection", + Annotations: map[string]string{ + "agent_notes": "Lists all threads (topics) in a collection, walking the full timeline. Use topic IDs with hey threads to read them. Get collection IDs from hey collections.", + }, + Example: ` hey collection 8328 + hey collection 8328 --json`, + RunE: collectionCommand.run, + Args: usageExactOneArg(), + } + + return collectionCommand +} + +func (c *collectionCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + collectionID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("invalid collection ID: %s", args[0])) + } + + // The collection detail endpoint is HTML-only (no JSON representation) and + // renders only the first ~50 threads per page. The rest live further down + // the timeline, reachable via the "next page" pagination link, so walk every + // page and accumulate the threads, de-duplicating across pages. + ctx := cmd.Context() + path := fmt.Sprintf("/collections/%d", collectionID) + var topics []htmlutil.CollectionTopic + seen := map[int64]bool{} + var truncated bool + + for page := 0; path != ""; page++ { + if page >= maxCollectionPages { + truncated = true + break + } + resp, err := sdk.GetHTML(ctx, path) + if err != nil { + return convertSDKError(err) + } + body := string(resp.Data) + for _, t := range htmlutil.ParseCollectionTopicsHTML(body) { + if !seen[t.TopicID] { + seen[t.TopicID] = true + topics = append(topics, t) + } + } + path = htmlutil.ParseCollectionNextPage(body) + } + + var notice string + if truncated { + notice = fmt.Sprintf("Stopped after %d pages; some older threads may be missing.", maxCollectionPages) + } + + if writer.IsStyled() { + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Topic ID", "Title"}) + for _, t := range topics { + table.addRow([]string{fmt.Sprintf("%d", t.TopicID), t.Title}) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + return writeOK(topics, + output.WithSummary(fmt.Sprintf("%d topics in collection %d", len(topics), collectionID)), + output.WithNotice(notice), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "read", + Command: "hey threads ", + Description: "Read a thread in this collection", + }), + ) +} diff --git a/internal/cmd/collections.go b/internal/cmd/collections.go new file mode 100644 index 0000000..7eee50b --- /dev/null +++ b/internal/cmd/collections.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "sort" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/output" +) + +type collectionsCommand struct { + cmd *cobra.Command + limit int + all bool +} + +func newCollectionsCommand() *collectionsCommand { + collectionsCommand := &collectionsCommand{} + collectionsCommand.cmd = &cobra.Command{ + Use: "collections", + Short: "List collections", + Annotations: map[string]string{ + "agent_notes": "Returns all email collections (labels). Sorted by name. Use --json for full detail.", + }, + Example: ` hey collections + hey collections --limit 5 + hey collections --json`, + RunE: collectionsCommand.run, + } + + collectionsCommand.cmd.Flags().IntVar(&collectionsCommand.limit, "limit", 0, "Maximum number of collections to show") + collectionsCommand.cmd.Flags().BoolVar(&collectionsCommand.all, "all", false, "Fetch all results (override --limit)") + + return collectionsCommand +} + +func (c *collectionsCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + ctx := cmd.Context() + + // Collections have no dedicated SDK service; fetch via the raw JSON endpoint. + resp, err := sdk.Get(ctx, "/collections.json") + if err != nil { + return convertSDKError(err) + } + + var collections []generated.Collection + if err := resp.UnmarshalData(&collections); err != nil { + return fmt.Errorf("decode collections: %w", err) + } + + sort.Slice(collections, func(i, j int) bool { + return collections[i].Name < collections[j].Name + }) + + total := len(collections) + if c.limit > 0 && !c.all && len(collections) > c.limit { + collections = collections[:c.limit] + } + notice := output.TruncationNotice(len(collections), total) + + if writer.IsStyled() { + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Name", "Updated"}) + for _, col := range collections { + table.addRow([]string{ + fmt.Sprintf("%d", col.Id), + col.Name, + col.UpdatedAt.Format("2006-01-02"), + }) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + return writeOK(collections, + output.WithSummary(fmt.Sprintf("%d collections", len(collections))), + output.WithNotice(notice), + ) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 568b3a2..26a7266 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -119,6 +119,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newAuthCommand().cmd) root.AddCommand(newBoxesCommand().cmd) root.AddCommand(newBoxCommand().cmd) + root.AddCommand(newCollectionsCommand().cmd) + root.AddCommand(newCollectionCommand().cmd) root.AddCommand(newThreadsCommand().cmd) root.AddCommand(newReplyCommand().cmd) root.AddCommand(newComposeCommand().cmd) diff --git a/internal/htmlutil/collections.go b/internal/htmlutil/collections.go new file mode 100644 index 0000000..571f75a --- /dev/null +++ b/internal/htmlutil/collections.go @@ -0,0 +1,72 @@ +package htmlutil + +import ( + "html" + "regexp" + "strconv" + "strings" +) + +var ( + // collectionAnchorRe matches an anchor linking to a topic, capturing the + // attributes before the href, the topic id, the attributes after the href, + // and the inner HTML. Attribute order varies, so class/title are inspected + // from the combined attribute text rather than assumed adjacent to href. + collectionAnchorRe = regexp.MustCompile(`(?s)]*?)href="/topics/(\d+)[^"]*"([^>]*)>(.*?)`) + titleAttrRe = regexp.MustCompile(`title="([^"]*)"`) + anyTagRe = regexp.MustCompile(`<[^>]+>`) + // collectionNextPageRe finds the timeline's "next page" pagination link. + collectionNextPageRe = regexp.MustCompile(`data-pagination-target="nextPageLink"[^>]*href="([^"]+)"`) +) + +// CollectionTopic is a single topic (thread) listed within a collection. +type CollectionTopic struct { + TopicID int64 `json:"topic_id"` + Title string `json:"title"` +} + +// ParseCollectionTopicsHTML extracts the threads on one page of a collection. +// Threads render either as card__link anchors (the recent items shown as cards) +// or entry__collection-topic anchors (older items further down the timeline). +// Other /topics/ links on the page — such as attachment "show message" links — +// are ignored. Results preserve document order and are de-duplicated by topic ID +// within the page. The collection detail endpoint has no JSON representation, so +// this HTML-based extraction is required. +func ParseCollectionTopicsHTML(page string) []CollectionTopic { + var topics []CollectionTopic + seen := map[int64]bool{} + for _, m := range collectionAnchorRe.FindAllStringSubmatch(page, -1) { + attrs := m[1] + m[3] + if !strings.Contains(attrs, "card__link") && !strings.Contains(attrs, "entry__collection-topic") { + continue + } + id, err := strconv.ParseInt(m[2], 10, 64) + if err != nil || seen[id] { + continue + } + seen[id] = true + + // Prefer the title attribute (a clean subject); fall back to inner text. + var title string + if tm := titleAttrRe.FindStringSubmatch(attrs); tm != nil { + title = tm[1] + } else { + title = anyTagRe.ReplaceAllString(m[4], "") + } + title = strings.TrimSpace(html.UnescapeString(title)) + + topics = append(topics, CollectionTopic{TopicID: id, Title: title}) + } + return topics +} + +// ParseCollectionNextPage returns the path of the next page of a collection's +// timeline, or "" when there is no further page. The path is used to walk the +// whole collection, since a single page only renders the first ~50 threads. +func ParseCollectionNextPage(page string) string { + m := collectionNextPageRe.FindStringSubmatch(page) + if m == nil { + return "" + } + return html.UnescapeString(m[1]) +} diff --git a/internal/htmlutil/collections_test.go b/internal/htmlutil/collections_test.go new file mode 100644 index 0000000..757813e --- /dev/null +++ b/internal/htmlutil/collections_test.go @@ -0,0 +1,43 @@ +package htmlutil + +import "testing" + +func TestParseCollectionTopicsHTML(t *testing.T) { + page := ` + + +
+ Re: Orsay Museum & tickets +
+ ` + + got := ParseCollectionTopicsHTML(page) + if len(got) != 2 { + t.Fatalf("expected 2 topics, got %d: %+v", len(got), got) + } + if got[0].TopicID != 111 || got[0].Title != "Flight confirmation" { + t.Errorf("topic[0] = %+v, want {111, Flight confirmation}", got[0]) + } + // Title should come from the title attribute (unescaped), not the "Re:" inner text. + if got[1].TopicID != 222 || got[1].Title != "Orsay Museum & tickets" { + t.Errorf("topic[1] = %+v, want {222, Orsay Museum & tickets}", got[1]) + } +} + +func TestParseCollectionNextPage(t *testing.T) { + with := `next` + if got := ParseCollectionNextPage(with); got != "/collections/5?page=abc%3D%3D&x=1" { + t.Errorf("ParseCollectionNextPage = %q, want unescaped path", got) + } + + without := `
no pagination here
` + if got := ParseCollectionNextPage(without); got != "" { + t.Errorf("ParseCollectionNextPage = %q, want empty", got) + } +}