Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/commands/gog-docs-comments-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ gog docs (doc) comments list (ls) <docId> [flags]
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
| `--include-resolved`<br>`--resolved` | `bool` | | Include resolved comments (default: open only) |
| `-j`<br>`--json`<br>`--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`<br>`--limit` | `int64` | 100 | Max results per page |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--page`<br>`--cursor` | `string` | | Page token for pagination |
Expand All @@ -42,6 +43,7 @@ gog docs (doc) comments list (ls) <docId> [flags]
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--select`<br>`--pick`<br>`--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`<br>`--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 |
Expand Down
20 changes: 20 additions & 0 deletions docs/docs-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <docId> --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
Expand Down
43 changes: 38 additions & 5 deletions internal/cmd/comment_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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()
Expand Down
117 changes: 114 additions & 3 deletions internal/cmd/docs_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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 == "" {
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -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"`
Expand Down
Loading