-
Notifications
You must be signed in to change notification settings - Fork 17
Add collections and collection commands
#140
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <id>", | ||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A collection page containing an absolute Prompt for AI agents |
||
| } | ||
|
|
||
| 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 <topic-id>", | ||
| Description: "Read a thread in this collection", | ||
| }), | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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"), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Calling Prompt for AI agents
Suggested change
|
||||||
| }) | ||||||
|
Comment on lines
+72
to
+76
|
||||||
| } | ||||||
| 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), | ||||||
| ) | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package htmlutil | ||
|
|
||
| import ( | ||
| "html" | ||
| "regexp" | ||
| "strconv" | ||
| "strings" | ||
| ) | ||
|
|
||
| var ( | ||
| // collectionAnchorRe matches an <a> 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)<a\b([^>]*?)href="/topics/(\d+)[^"]*"([^>]*)>(.*?)</a>`) | ||
| titleAttrRe = regexp.MustCompile(`title="([^"]*)"`) | ||
| anyTagRe = regexp.MustCompile(`<[^>]+>`) | ||
| // collectionNextPageRe finds the timeline's "next page" pagination link. | ||
| collectionNextPageRe = regexp.MustCompile(`data-pagination-target="nextPageLink"[^>]*href="([^"]+)"`) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Collections silently truncate when the pagination anchor emits Prompt for AI agents |
||
| ) | ||
|
Comment on lines
+18
to
+20
|
||
|
|
||
| // 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]) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package htmlutil | ||
|
|
||
| import "testing" | ||
|
|
||
| func TestParseCollectionTopicsHTML(t *testing.T) { | ||
| page := ` | ||
| <article class="card card--topic"> | ||
| <a class="card__link" href="/topics/111">Flight confirmation</a> | ||
| </article> | ||
| <article class="card card--topic"> | ||
| <a class="card__link" href="/topics/111">Flight confirmation</a> | ||
| </article> | ||
| <div class="entry"> | ||
| <a title="Orsay Museum & tickets" class="entry__collection-topic undecorated" data-action="x" href="/topics/222">Re: Orsay Museum & tickets</a> | ||
| </div> | ||
| <div class="attachment"> | ||
| <a aria-label="Show message" class="undecorated" href="/topics/111#__entry_9">receipt.pdf</a> | ||
| </div>` | ||
|
|
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The existing test only exercises the case where Prompt for AI agents |
||
| with := `<a class="pagination-link" data-pagination-target="nextPageLink" href="/collections/5?page=abc%3D%3D&x=1">next</a>` | ||
| if got := ParseCollectionNextPage(with); got != "/collections/5?page=abc%3D%3D&x=1" { | ||
| t.Errorf("ParseCollectionNextPage = %q, want unescaped path", got) | ||
| } | ||
|
Comment on lines
+34
to
+37
|
||
|
|
||
| without := `<div>no pagination here</div>` | ||
| if got := ParseCollectionNextPage(without); got != "" { | ||
| t.Errorf("ParseCollectionNextPage = %q, want empty", got) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Empty collections return
"data": nullin JSON/quiet output instead of an empty list, forcing clients to handle a different result type. Initializetopicsto an empty slice.Prompt for AI agents