diff --git a/docs/commands/gog-docs-comments-list.md b/docs/commands/gog-docs-comments-list.md index 060615a05..81b56505c 100644 --- a/docs/commands/gog-docs-comments-list.md +++ b/docs/commands/gog-docs-comments-list.md @@ -34,6 +34,7 @@ gog docs (doc) comments list (ls) [flags] | `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | | `--include-resolved`
`--resolved` | `bool` | | Include resolved comments (default: open only) | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--locate` | `bool` | | Attach each comment's tab and index ranges (one extra Docs fetch) | | `--max`
`--limit` | `int64` | 100 | Max results per page | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--page`
`--cursor` | `string` | | Page token for pagination | @@ -42,6 +43,7 @@ gog docs (doc) comments list (ls) [flags] | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | | `--since` | `string` | | Only return comments modified at or after this RFC3339 timestamp | +| `--tab` | `string` | | Only comments located in this tab by title or ID (implies --locate) | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | diff --git a/docs/docs-editing.md b/docs/docs-editing.md index 7fd49bf4d..cc7fe487b 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -135,6 +135,26 @@ Use `--occurrence N` when an anchor is ambiguous and `--match-case` when case must be exact. `docs comments locate` applies the same matching rules to a comment's quoted text and reports its tab plus UTF-16 range. +`docs comments list --locate` does the same for every listed comment from a +single document fetch, adding a `location` object (`matches`, `orphaned`) and a +`TAB` column. `--tab` implies `--locate`, adds the resolved tab to JSON output +as a top-level `tab` (`{id, title}`), and keeps only the comments with at least +one match in that tab: + +```bash +gog docs comments list --tab "Planning" --all --json +``` + +Anything that resolves to no tab therefore drops out under `--tab`: comments +whose quote was edited away (`orphaned`), and comments with no quoted text at +all, such as ones attached to the whole document. Use `--locate` alone to see +them - it reports every comment, orphaned or not. + +Both flags are opt-in: without them the command still reads Drive alone. +`matches` always spans every tab, so a quote that appears more than once stays +visible as ambiguous. An explicitly empty `--tab` value is rejected instead of +silently falling back to an unfiltered listing. + `insert` and `update` both accept `--markdown` to convert the content (headings, fenced code blocks, lists, tables, images) before placing it at the resolved position. `insert --markdown` adds the block without deleting anything; `update diff --git a/internal/cmd/comment_ops.go b/internal/cmd/comment_ops.go index a3d50fe2d..7088df0c4 100644 --- a/internal/cmd/comment_ops.go +++ b/internal/cmd/comment_ops.go @@ -221,13 +221,26 @@ func writeDriveCommentList(ctx context.Context, u *ui.UI, opts driveCommentListO } func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) { + located := make([]*driveCommentWithLocation, 0, len(comments)) + for _, comment := range comments { + located = append(located, &driveCommentWithLocation{Comment: comment}) + } + printExpandedCommentRows(ctx, located, false) +} + +func printExpandedCommentRows(ctx context.Context, located []*driveCommentWithLocation, showTab bool) { w, flush := tableWriter(ctx) defer flush() - fmt.Fprintln(w, "TYPE\tID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tACTION") - for _, comment := range comments { - if comment == nil { + header := "TYPE\tID\tAUTHOR\tQUOTED\tCONTENT\tCREATED\tRESOLVED\tACTION" + if showTab { + header += "\tTAB" + } + fmt.Fprintln(w, header) + for _, item := range located { + if item == nil || item.Comment == nil { continue } + comment := item.Comment author := "" if comment.Author != nil { author = comment.Author.DisplayName @@ -236,7 +249,7 @@ func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) { if comment.QuotedFileContent != nil { quoted = truncateString(oneLineTSV(comment.QuotedFileContent.Value), 30) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\n", + row := fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s", "comment", comment.Id, oneLineTSV(author), @@ -246,6 +259,10 @@ func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) { comment.Resolved, "", ) + if showTab { + row += "\t" + commentLocationTabCell(item) + } + fmt.Fprintln(w, row) for _, reply := range comment.Replies { if reply == nil { continue @@ -254,7 +271,7 @@ func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) { if reply.Author != nil { author = reply.Author.DisplayName } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + row = fmt.Sprintf("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s", "reply", reply.Id, oneLineTSV(author), @@ -264,10 +281,26 @@ func printExpandedCommentTable(ctx context.Context, comments []*drive.Comment) { "", oneLineTSV(reply.Action), ) + if showTab { + row += "\t" + } + fmt.Fprintln(w, row) } } } +// commentLocationTabCell renders "-" for documents without tabs, where a +// located comment still has no tab to name. +func commentLocationTabCell(item *driveCommentWithLocation) string { + if item.Location != nil && item.Location.Orphaned { + return "(orphaned)" + } + if len(item.tabLabels) == 0 { + return "-" + } + return oneLineTSV(strings.Join(item.tabLabels, ", ")) +} + func printCompactCommentTable(ctx context.Context, comments []*drive.Comment, includeQuoted bool) { w, flush := tableWriter(ctx) defer flush() diff --git a/internal/cmd/docs_comments.go b/internal/cmd/docs_comments.go index 44d0c3e82..a5441bf55 100644 --- a/internal/cmd/docs_comments.go +++ b/internal/cmd/docs_comments.go @@ -6,6 +6,11 @@ import ( "fmt" "strings" + "github.com/alecthomas/kong" + "google.golang.org/api/docs/v1" + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) @@ -31,9 +36,12 @@ type DocsCommentsListCmd struct { All bool `name:"all" aliases:"all-pages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` Since string `name:"since" help:"Only return comments modified at or after this RFC3339 timestamp"` + Locate bool `name:"locate" help:"Attach each comment's tab and index ranges (one extra Docs fetch)"` + Tab string `name:"tab" help:"Only comments located in this tab by title or ID (implies --locate)"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` } -func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { +func (c *DocsCommentsListCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { u := ui.FromContext(ctx) docID := normalizeGoogleID(strings.TrimSpace(c.DocID)) if docID == "" { @@ -46,12 +54,20 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } + tab, err := resolveTabArg(ctx, c.Tab, c.TabID) + if err != nil { + return err + } + if tab == "" && (flagProvided(kctx, "tab") || flagProvided(kctx, "tab-id")) { + return usage("--tab requires a non-empty tab title or ID") + } + c.Tab = tab _, svc, err := requireDriveService(ctx, flags) if err != nil { return err } - comments, nextPageToken, err := listDriveComments(ctx, svc, docID, driveCommentListOptions{ + listOpts := driveCommentListOptions{ resourceKey: "docId", resourceID: docID, includeResolved: c.IncludeResolved, @@ -63,7 +79,13 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { max: c.Max, emptyMessage: "No comments", mode: driveCommentListModeExpanded, - }) + } + + if c.Locate || tab != "" { + return c.runLocated(ctx, u, flags, svc, docID, tab, listOpts) + } + + comments, nextPageToken, err := listDriveComments(ctx, svc, docID, listOpts) if err != nil { return err } @@ -76,6 +98,95 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { }, comments, nextPageToken) } +// runLocated shares a single documents.get across every comment, and across +// every page walked while looking for tab matches. +func (c *DocsCommentsListCmd) runLocated( + ctx context.Context, + u *ui.UI, + flags *RootFlags, + svc *drive.Service, + docID string, + tab string, + listOpts driveCommentListOptions, +) error { + docsSvc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + locator, err := newDocsCommentLocator(ctx, docsSvc, docID, tab) + if err != nil { + return err + } + + located, nextPageToken, err := c.collectLocatedComments(ctx, svc, docID, listOpts, locator) + if err != nil { + return err + } + return writeDocsCommentListWithLocations(ctx, u, docID, c.FailEmpty, locator.targetTab, located, nextPageToken) +} + +// collectLocatedComments walks Drive pages until the tab filter yields at +// least one comment, mirroring how listDriveComments scans for open comments. +func (c *DocsCommentsListCmd) collectLocatedComments( + ctx context.Context, + svc *drive.Service, + docID string, + listOpts driveCommentListOptions, + locator *docsCommentLocator, +) ([]*driveCommentWithLocation, string, error) { + seen := map[string]bool{} + pageToken := listOpts.page + for { + listOpts.page = pageToken + comments, nextPageToken, err := listDriveComments(ctx, svc, docID, listOpts) + if err != nil { + return nil, "", err + } + located := locator.attach(comments) + nextPageToken = strings.TrimSpace(nextPageToken) + if locator.targetTab == nil || len(located) > 0 || nextPageToken == "" || seen[nextPageToken] { + return located, nextPageToken, nil + } + seen[nextPageToken] = true + pageToken = nextPageToken + } +} + +func writeDocsCommentListWithLocations( + ctx context.Context, + u *ui.UI, + docID string, + failEmpty bool, + targetTab *docs.Tab, + located []*driveCommentWithLocation, + nextPageToken string, +) error { + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "docId": docID, + "comments": located, + "nextPageToken": nextPageToken, + } + if tab := newDocsCommentListTab(targetTab); tab != nil { + payload["tab"] = tab + } + return writePagedJSONResult(ctx, payload, len(located), failEmpty) + } + + if len(located) == 0 { + if targetTab != nil { + u.Err().Linef("No comments located in tab %q", docsCommentTabLabel(targetTab)) + } else { + u.Err().Println("No comments") + } + return failEmptyExit(failEmpty) + } + + printExpandedCommentRows(ctx, located, true) + printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") + return nil +} + // DocsCommentsGetCmd retrieves a single comment by ID. type DocsCommentsGetCmd struct { DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"` diff --git a/internal/cmd/docs_comments_list_locate_test.go b/internal/cmd/docs_comments_list_locate_test.go new file mode 100644 index 000000000..a96f730ce --- /dev/null +++ b/internal/cmd/docs_comments_list_locate_test.go @@ -0,0 +1,462 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "google.golang.org/api/docs/v1" + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/docsedit" + "github.com/steipete/gogcli/internal/outfmt" +) + +type docsCommentsListPage struct { + comments []map[string]any + nextPageToken string +} + +type docsCommentsListLocatedJSON struct { + DocID string `json:"docId"` + Tab *docsCommentListTab `json:"tab"` + Comments []struct { + ID string `json:"id"` + Content string `json:"content"` + QuotedFileContent *drive.CommentQuotedFileContent `json:"quotedFileContent"` + Location *docsCommentLocation `json:"location"` + } `json:"comments"` + NextPageToken string `json:"nextPageToken"` +} + +func docsCommentFixture(id, quote string) map[string]any { + comment := map[string]any{"id": id, "content": "note " + id, "resolved": false} + if quote != "" { + comment["quotedFileContent"] = map[string]any{"value": quote} + } + return comment +} + +// newDocsCommentsListService serves comments.list pages for doc1. Page N is +// requested with pageToken "pN"; each page declares its own nextPageToken. +func newDocsCommentsListService(t *testing.T, pages ...docsCommentsListPage) *drive.Service { + t.Helper() + byToken := make(map[string]docsCommentsListPage, len(pages)) + for i, page := range pages { + token := "" + if i > 0 { + token = fmt.Sprintf("p%d", i) + } + byToken[token] = page + } + return newDriveCommentsTestService(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || strings.TrimPrefix(r.URL.Path, "/drive/v3") != "/files/doc1/comments" { + http.NotFound(w, r) + return + } + page, ok := byToken[r.URL.Query().Get("pageToken")] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "comments": page.comments, + "nextPageToken": page.nextPageToken, + }) + }) +} + +// docsCommentsListTabsDoc is a two-tab document where "shared quote" appears +// in both tabs. +func docsCommentsListTabsDoc() *docs.Document { + return &docs.Document{ + DocumentId: "doc1", + Tabs: []*docs.Tab{ + { + TabProperties: &docs.TabProperties{TabId: "t.first", Title: "First"}, + DocumentTab: &docs.DocumentTab{Body: docsFindRangeDoc( + docsFindRangeParagraph(1, "first tab quote\n"), + docsFindRangeParagraph(17, "shared quote\n"), + ).Body}, + }, + { + TabProperties: &docs.TabProperties{TabId: "t.second", Title: "Second"}, + DocumentTab: &docs.DocumentTab{Body: docsFindRangeDoc( + docsFindRangeParagraph(1, "second tab quote\n"), + docsFindRangeParagraph(18, "shared quote\n"), + ).Body}, + }, + }, + } +} + +func newCountingDocsDocumentTestService(t *testing.T, document any, fetches *int, includeTabs *string) *docs.Service { + t.Helper() + svc, _ := newDocsServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/v1/documents/") { + http.NotFound(w, r) + return + } + if fetches != nil { + *fetches++ + } + if includeTabs != nil { + *includeTabs = r.URL.Query().Get("includeTabsContent") + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(document) + }) + return svc +} + +func runDocsCommentsListJSON(t *testing.T, driveSvc *drive.Service, docsSvc *docs.Service, args ...string) executeTestResult { + t.Helper() + + var stdout, stderr bytes.Buffer + ctx := docsCommentsListTestContext(t, newCmdRuntimeJSONOutputContext(t, &stdout, &stderr), driveSvc, docsSvc) + err := runKong(t, &DocsCommentsListCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}) + return executeTestResult{stdout: stdout.String(), stderr: stderr.String(), err: err} +} + +func docsCommentsListTestContext(t *testing.T, ctx context.Context, driveSvc *drive.Service, docsSvc *docs.Service) context.Context { + t.Helper() + ctx = withDriveTestService(ctx, driveSvc) + if docsSvc == nil { + return withDocsTestServiceFactory(ctx, func(context.Context, string) (*docs.Service, error) { + t.Fatal("comments list must not create a Docs service without --locate/--tab") + return nil, errors.New("unexpected Docs service creation") + }) + } + return withDocsTestService(ctx, docsSvc) +} + +func parseDocsCommentsListLocated(t *testing.T, stdout string) docsCommentsListLocatedJSON { + t.Helper() + var parsed docsCommentsListLocatedJSON + if err := json.Unmarshal([]byte(stdout), &parsed); err != nil { + t.Fatalf("json: %v\n%s", err, stdout) + } + return parsed +} + +func TestDocsCommentsListDefaultSkipsDocsFetch(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{ + comments: []map[string]any{docsCommentFixture("c1", "first tab quote")}, + }) + + result := runDocsCommentsListJSON(t, driveSvc, nil, "doc1") + if result.err != nil { + t.Fatalf("list: %v", result.err) + } + if strings.Contains(result.stdout, "location") { + t.Fatalf("default output must not carry location data: %s", result.stdout) + } + + var parsed struct { + Comments []*drive.Comment `json:"comments"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("json: %v\n%s", err, result.stdout) + } + if len(parsed.Comments) != 1 || parsed.Comments[0].Id != "c1" { + t.Fatalf("comments = %#v", parsed.Comments) + } +} + +func TestDocsCommentsListLocateSharesOneDocumentFetch(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "first tab quote"), + docsCommentFixture("c2", "second tab quote"), + docsCommentFixture("c3", "deleted quote"), + docsCommentFixture("c4", ""), + }}) + + fetches := 0 + includeTabs := "" + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), &fetches, &includeTabs) + + result := runDocsCommentsListJSON(t, driveSvc, docsSvc, "doc1", "--locate") + if result.err != nil { + t.Fatalf("list --locate: %v", result.err) + } + if fetches != 1 { + t.Fatalf("documents.get calls = %d, want 1", fetches) + } + if includeTabs != "true" { + t.Fatalf("includeTabsContent = %q, want true", includeTabs) + } + + parsed := parseDocsCommentsListLocated(t, result.stdout) + if len(parsed.Comments) != 4 { + t.Fatalf("comments = %d, want 4 (orphans reported, not dropped)", len(parsed.Comments)) + } + if parsed.Tab != nil { + t.Fatalf("tab = %#v, want none without --tab", parsed.Tab) + } + + byID := map[string]*docsCommentLocation{} + for _, comment := range parsed.Comments { + if comment.Location == nil { + t.Fatalf("comment %s has no location", comment.ID) + } + byID[comment.ID] = comment.Location + } + if got := byID["c1"]; got.Orphaned || len(got.Matches) != 1 || got.Matches[0].TabID != "t.first" { + t.Fatalf("c1 location = %#v", got) + } + if got := byID["c2"]; got.Orphaned || len(got.Matches) != 1 || got.Matches[0].TabID != "t.second" { + t.Fatalf("c2 location = %#v", got) + } + if got := byID["c3"]; !got.Orphaned || len(got.Matches) != 0 { + t.Fatalf("c3 location = %#v, want orphaned with no matches", got) + } + if got := byID["c4"]; !got.Orphaned || len(got.Matches) != 0 { + t.Fatalf("c4 (unquoted) location = %#v, want orphaned with no matches", got) + } +} + +func TestDocsCommentsListTabFiltersAndImpliesLocate(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "first tab quote"), + docsCommentFixture("c2", "second tab quote"), + docsCommentFixture("c3", "deleted quote"), + docsCommentFixture("c5", "shared quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), nil, nil) + + result := runDocsCommentsListJSON(t, driveSvc, docsSvc, "doc1", "--tab", "Second") + if result.err != nil { + t.Fatalf("list --tab: %v", result.err) + } + + parsed := parseDocsCommentsListLocated(t, result.stdout) + if parsed.Tab == nil || parsed.Tab.ID != "t.second" || parsed.Tab.Title != "Second" { + t.Fatalf("tab = %#v, want the resolved second tab", parsed.Tab) + } + if len(parsed.Comments) != 2 { + t.Fatalf("comments = %d, want c2 and c5 only", len(parsed.Comments)) + } + for _, comment := range parsed.Comments { + if comment.ID != "c2" && comment.ID != "c5" { + t.Fatalf("unexpected comment %s (orphans and other tabs must be filtered out)", comment.ID) + } + if comment.Location == nil { + t.Fatalf("--tab must imply --locate, comment %s has no location", comment.ID) + } + } +} + +func TestDocsCommentsListTabKeepsCrossTabMatches(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c5", "shared quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), nil, nil) + + result := runDocsCommentsListJSON(t, driveSvc, docsSvc, "doc1", "--tab", "t.second") + if result.err != nil { + t.Fatalf("list --tab: %v", result.err) + } + + parsed := parseDocsCommentsListLocated(t, result.stdout) + if len(parsed.Comments) != 1 { + t.Fatalf("comments = %d, want 1", len(parsed.Comments)) + } + matches := parsed.Comments[0].Location.Matches + if len(matches) != 2 { + t.Fatalf("matches = %#v, want both tabs so callers can spot ambiguity", matches) + } + if matches[0].TabID != "t.first" || matches[1].TabID != "t.second" { + t.Fatalf("matches = %#v, want one per tab", matches) + } +} + +func TestDocsCommentsListTabScansPagesForMatches(t *testing.T) { + driveSvc := newDocsCommentsListService(t, + docsCommentsListPage{ + comments: []map[string]any{docsCommentFixture("c1", "first tab quote")}, + nextPageToken: "p1", + }, + docsCommentsListPage{ + comments: []map[string]any{docsCommentFixture("c2", "second tab quote")}, + }, + ) + + fetches := 0 + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), &fetches, nil) + + result := runDocsCommentsListJSON(t, driveSvc, docsSvc, "doc1", "--tab", "Second") + if result.err != nil { + t.Fatalf("list --tab: %v", result.err) + } + if fetches != 1 { + t.Fatalf("documents.get calls = %d, want 1 across the page scan", fetches) + } + + parsed := parseDocsCommentsListLocated(t, result.stdout) + if len(parsed.Comments) != 1 || parsed.Comments[0].ID != "c2" { + t.Fatalf("comments = %#v, want the match from the second page", parsed.Comments) + } +} + +func TestDocsCommentsListTabFailEmptyExits(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "first tab quote"), + docsCommentFixture("c3", "deleted quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), nil, nil) + + result := runDocsCommentsListJSON(t, driveSvc, docsSvc, "doc1", "--tab", "Second", "--fail-empty") + var exitErr *ExitError + if !errors.As(result.err, &exitErr) || exitErr.Code != emptyResultsExitCode { + t.Fatalf("err = %#v, want empty-results exit %d", result.err, emptyResultsExitCode) + } + + parsed := parseDocsCommentsListLocated(t, result.stdout) + if len(parsed.Comments) != 0 { + t.Fatalf("comments = %#v, want none", parsed.Comments) + } +} + +func TestDocsCommentsListTabUnknown(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "first tab quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), nil, nil) + + result := runDocsCommentsListJSON(t, driveSvc, docsSvc, "doc1", "--tab", "Missing") + if result.err == nil || !strings.Contains(result.err.Error(), "tab not found") { + t.Fatalf("err = %v, want a tab-not-found error", result.err) + } + var exitErr *ExitError + if errors.As(result.err, &exitErr) { + t.Fatalf("err = %#v, want a plain error, not an exit code", result.err) + } +} + +func TestDocsCommentsListTabRejectsExplicitEmptyValues(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{}) + + for _, args := range [][]string{ + {"doc1", "--tab", " "}, + {"doc1", "--tab-id", " "}, + } { + result := runDocsCommentsListJSON(t, driveSvc, nil, args...) + if result.err == nil || !strings.Contains(result.err.Error(), "--tab requires a non-empty tab title or ID") { + t.Fatalf("args %v: err = %v, want explicit-empty tab rejection", args, result.err) + } + } +} + +func TestDocsCommentsListLocatePlainTable(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "first tab quote"), + docsCommentFixture("c3", "deleted quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), nil, nil) + + var stdout, stderr bytes.Buffer + ctx := outfmt.WithMode(newCmdRuntimeOutputContext(t, &stdout, &stderr), outfmt.Mode{Plain: true}) + ctx = docsCommentsListTestContext(t, ctx, driveSvc, docsSvc) + if err := runKong(t, &DocsCommentsListCmd{}, []string{"doc1", "--locate"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("list --locate --plain: %v", err) + } + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("lines = %#v, want header plus two comments", lines) + } + if !strings.HasSuffix(lines[0], "\tTAB") { + t.Fatalf("header = %q, want a trailing TAB column", lines[0]) + } + if !strings.HasSuffix(lines[1], "\tFirst") { + t.Fatalf("row = %q, want the resolved tab title", lines[1]) + } + if !strings.HasSuffix(lines[2], "\t(orphaned)") { + t.Fatalf("row = %q, want the orphan marker", lines[2]) + } +} + +func TestDocsCommentsListLocateDocumentWithoutTabs(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "untabbed quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsFindRangeDoc(docsFindRangeParagraph(1, "untabbed quote\n")), nil, nil) + + var stdout, stderr bytes.Buffer + ctx := outfmt.WithMode(newCmdRuntimeOutputContext(t, &stdout, &stderr), outfmt.Mode{Plain: true}) + ctx = docsCommentsListTestContext(t, ctx, driveSvc, docsSvc) + if err := runKong(t, &DocsCommentsListCmd{}, []string{"doc1", "--locate"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("list --locate: %v", err) + } + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("lines = %#v, want header plus one comment", lines) + } + // A document without tabs resolves to an empty tab ID, so the column has + // nothing to name - but the comment is located, not orphaned. + if !strings.HasSuffix(lines[1], "\t-") { + t.Fatalf("row = %q, want the no-tab placeholder", lines[1]) + } +} + +func TestDocsCommentsListTabEmptyMessageNamesTab(t *testing.T) { + driveSvc := newDocsCommentsListService(t, docsCommentsListPage{comments: []map[string]any{ + docsCommentFixture("c1", "first tab quote"), + }}) + docsSvc := newCountingDocsDocumentTestService(t, docsCommentsListTabsDoc(), nil, nil) + + var stdout, stderr bytes.Buffer + ctx := docsCommentsListTestContext(t, newCmdRuntimeOutputContext(t, &stdout, &stderr), driveSvc, docsSvc) + if err := runKong(t, &DocsCommentsListCmd{}, []string{"doc1", "--tab", "t.second"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("list --tab: %v", err) + } + + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty", got) + } + // The message names the tab by title even when --tab was given as an ID. + if got := stderr.String(); !strings.Contains(got, `No comments located in tab "Second"`) { + t.Fatalf("stderr = %q, want the resolved tab title", got) + } +} + +func TestDriveCommentWithLocationMarshalJSON(t *testing.T) { + // drive.Comment declares MarshalJSON on a value receiver; without the + // override on driveCommentWithLocation the promoted method would silently + // drop the location key. + item := &driveCommentWithLocation{ + Comment: &drive.Comment{ + Id: "c1", + Content: "note", + QuotedFileContent: &drive.CommentQuotedFileContent{Value: "quote"}, + }, + Location: &docsCommentLocation{Matches: []docsedit.TextRange{{StartIndex: 1, EndIndex: 6, TabID: "t.first"}}}, + } + + data, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var parsed struct { + ID string `json:"id"` + Content string `json:"content"` + QuotedFileContent *drive.CommentQuotedFileContent `json:"quotedFileContent"` + Location *docsCommentLocation `json:"location"` + } + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, data) + } + if parsed.ID != "c1" || parsed.Content != "note" || parsed.QuotedFileContent == nil { + t.Fatalf("comment fields lost: %s", data) + } + if parsed.Location == nil || len(parsed.Location.Matches) != 1 || parsed.Location.Matches[0].TabID != "t.first" { + t.Fatalf("location lost: %s", data) + } +} diff --git a/internal/cmd/docs_comments_locate_batch.go b/internal/cmd/docs_comments_locate_batch.go new file mode 100644 index 000000000..bdd4bda5c --- /dev/null +++ b/internal/cmd/docs_comments_locate_batch.go @@ -0,0 +1,188 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "google.golang.org/api/docs/v1" + "google.golang.org/api/drive/v3" + + "github.com/steipete/gogcli/internal/docsedit" +) + +// docsCommentLocation is the per-comment attribution attached by --locate. +// Matches always cover every tab so callers can spot a quote that resolves in +// more than one place. +type docsCommentLocation struct { + Matches []docsedit.TextRange `json:"matches"` + Orphaned bool `json:"orphaned"` +} + +// driveCommentWithLocation renders a Drive comment plus a "location" key. +// drive.Comment declares MarshalJSON on a value receiver, so the method is +// promoted onto this struct and would drop Location without the override +// below - the same pattern eventWithCalendar uses in calendar_list.go. +type driveCommentWithLocation struct { + *drive.Comment + Location *docsCommentLocation + tabLabels []string +} + +func (c *driveCommentWithLocation) MarshalJSON() ([]byte, error) { + if c == nil { + return []byte("null"), nil + } + raw := map[string]any{} + if c.Comment != nil { + data, err := json.Marshal(c.Comment) + if err != nil { + return nil, err + } + if string(data) != "null" { + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + } + } + if c.Location != nil { + raw["location"] = c.Location + } + return json.Marshal(raw) +} + +// docsCommentLocator resolves any number of comments against a single fetched +// document, where `docs comments locate` costs one fetch per comment. +type docsCommentLocator struct { + locator DocsCommentsLocateCmd + doc *docs.Document + tabTitles map[string]string + targetTab *docs.Tab +} + +// newDocsCommentLocator expects tabQuery to have gone through resolveTabArg. +func newDocsCommentLocator(ctx context.Context, svc *docs.Service, docID, tabQuery string) (*docsCommentLocator, error) { + doc, err := fetchDocForCommentLocation(ctx, svc, docID) + if err != nil { + return nil, err + } + + tabs := flattenTabs(doc.Tabs) + titles := make(map[string]string, len(tabs)) + for _, tab := range tabs { + if tab.TabProperties == nil { + continue + } + titles[tab.TabProperties.TabId] = tab.TabProperties.Title + } + + located := &docsCommentLocator{ + locator: DocsCommentsLocateCmd{NormalizeWhitespace: true}, + doc: doc, + tabTitles: titles, + } + if strings.TrimSpace(tabQuery) == "" { + return located, nil + } + + tab, err := findTab(tabs, tabQuery) + if err != nil { + return nil, err + } + if tab.TabProperties == nil || strings.TrimSpace(tab.TabProperties.TabId) == "" { + return nil, fmt.Errorf("tab has no ID: %s", tabQuery) + } + located.targetTab = tab + return located, nil +} + +func fetchDocForCommentLocation(ctx context.Context, svc *docs.Service, docID string) (*docs.Document, error) { + doc, err := svc.Documents.Get(docID).Context(ctx).IncludeTabsContent(true).Do() + if err != nil { + if isDocsNotFound(err) { + return nil, fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return nil, err + } + return requireRawResponse(doc, "doc not found") +} + +// attach drops comments without a match in the target tab, which includes +// orphans and comments with no quoted text at all. +func (l *docsCommentLocator) attach(comments []*drive.Comment) []*driveCommentWithLocation { + located := make([]*driveCommentWithLocation, 0, len(comments)) + for _, comment := range comments { + if comment == nil { + continue + } + location := l.resolve(comment) + if l.targetTab != nil && !locationTouchesTab(location, l.targetTab.TabProperties.TabId) { + continue + } + located = append(located, &driveCommentWithLocation{ + Comment: comment, + Location: &location, + tabLabels: l.tabLabels(location), + }) + } + return located +} + +func (l *docsCommentLocator) resolve(comment *drive.Comment) docsCommentLocation { + quote := docsCommentQuote(comment) + matches := []docsedit.TextRange{} + if strings.TrimSpace(quote) != "" { + matches = append(matches, l.locator.findQuoteMatchesAcrossDocument(l.doc, quote)...) + } + return docsCommentLocation{Matches: matches, Orphaned: len(matches) == 0} +} + +func (l *docsCommentLocator) tabLabels(location docsCommentLocation) []string { + var labels []string + seen := map[string]bool{} + for _, match := range location.Matches { + label := strings.TrimSpace(l.tabTitles[match.TabID]) + if label == "" { + label = match.TabID + } + if label == "" || seen[label] { + continue + } + seen[label] = true + labels = append(labels, label) + } + return labels +} + +func locationTouchesTab(location docsCommentLocation, tabID string) bool { + for _, match := range location.Matches { + if match.TabID == tabID { + return true + } + } + return false +} + +// docsCommentListTab is the resolved --tab reported in JSON output. +type docsCommentListTab struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` +} + +func docsCommentTabLabel(tab *docs.Tab) string { + if tab == nil || tab.TabProperties == nil { + return "" + } + if title := strings.TrimSpace(tab.TabProperties.Title); title != "" { + return title + } + return tab.TabProperties.TabId +} + +func newDocsCommentListTab(tab *docs.Tab) *docsCommentListTab { + if tab == nil || tab.TabProperties == nil { + return nil + } + return &docsCommentListTab{ID: tab.TabProperties.TabId, Title: tab.TabProperties.Title} +} diff --git a/internal/cmd/docs_comments_test.go b/internal/cmd/docs_comments_test.go index 9fe2dfa7d..f7cc60988 100644 --- a/internal/cmd/docs_comments_test.go +++ b/internal/cmd/docs_comments_test.go @@ -524,10 +524,10 @@ func TestDocsComments_ValidationErrors(t *testing.T) { ctx := ui.WithUI(context.Background(), u) flags := &RootFlags{Account: "a@b.com"} - if err := (&DocsCommentsListCmd{}).Run(ctx, flags); err == nil { + if err := (&DocsCommentsListCmd{}).Run(ctx, nil, flags); err == nil { t.Fatal("expected list missing docId error") } - if err := (&DocsCommentsListCmd{DocID: "d1", Max: 1, Since: "2026-06-04T10:00:00"}).Run(ctx, flags); err == nil { + if err := (&DocsCommentsListCmd{DocID: "d1", Max: 1, Since: "2026-06-04T10:00:00"}).Run(ctx, nil, flags); err == nil { t.Fatal("expected list invalid since error") } if err := (&DocsCommentsGetCmd{}).Run(ctx, flags); err == nil {