Skip to content

Commit cf9c515

Browse files
authored
feat(docs): add table cell update command (#666)
1 parent 2c844b1 commit cf9c515

9 files changed

Lines changed: 502 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Sheets: add `--inherit-from-before` to `sheets insert` so callers can choose whether inserted rows/columns inherit formatting from the preceding or following neighbor. (#655, #658) — thanks @chrischall.
1010
- Auth: update stored OAuth scope metadata from observed granted scopes during refresh so `auth list` reflects newly usable services. (#649)
1111
- Docs: preserve paragraph-separating blank lines when replacing a single tab from Markdown. (#644)
12+
- Docs: add `docs cell-update` for non-destructive table-cell content replacement by table, row, and column. (#646)
1213
- Gmail: pause watch push Gmail API fetches per account while a 429 Retry-After circuit is open. (#643)
1314
- Docs: update the bundled `gog` agent skill to preserve broad user OAuth scopes during reauth and rely on command guards for scoped execution.
1415

docs/commands.generated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ Generated from `gog schema --json`.
220220
- [`gog docs (doc) <command> [flags]`](commands/gog-docs.md) - Google Docs (export via Drive)
221221
- [`gog docs (doc) add-tab <docId> [flags]`](commands/gog-docs-add-tab.md) - Add a tab to a Google Doc
222222
- [`gog docs (doc) cat (text,read) <docId> [flags]`](commands/gog-docs-cat.md) - Print a Google Doc as plain text
223+
- [`gog docs (doc) cell-update (update-cell) --row=INT --col=INT <docId> [flags]`](commands/gog-docs-cell-update.md) - Replace or append content inside a specific table cell
223224
- [`gog docs (doc) clear <docId>`](commands/gog-docs-clear.md) - Clear all content from a Google Doc
224225
- [`gog docs (doc) comments <command>`](commands/gog-docs-comments.md) - Manage comments on files
225226
- [`gog docs (doc) comments add (create,new) <docId> <content> [flags]`](commands/gog-docs-comments-add.md) - Add a comment to a Google Doc

docs/commands/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments.
44

5-
Generated pages: 572.
5+
Generated pages: 573.
66

77
## Top-level Commands
88

@@ -271,6 +271,7 @@ Generated pages: 572.
271271
- [gog docs](gog-docs.md) - Google Docs (export via Drive)
272272
- [gog docs add-tab](gog-docs-add-tab.md) - Add a tab to a Google Doc
273273
- [gog docs cat](gog-docs-cat.md) - Print a Google Doc as plain text
274+
- [gog docs cell-update](gog-docs-cell-update.md) - Replace or append content inside a specific table cell
274275
- [gog docs clear](gog-docs-clear.md) - Clear all content from a Google Doc
275276
- [gog docs comments](gog-docs-comments.md) - Manage comments on files
276277
- [gog docs comments add](gog-docs-comments-add.md) - Add a comment to a Google Doc
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# `gog docs cell-update`
2+
3+
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
4+
5+
Replace or append content inside a specific table cell
6+
7+
## Usage
8+
9+
```bash
10+
gog docs (doc) cell-update (update-cell) --row=INT --col=INT <docId> [flags]
11+
```
12+
13+
## Parent
14+
15+
- [gog docs](gog-docs.md)
16+
17+
## Flags
18+
19+
| Flag | Type | Default | Help |
20+
| --- | --- | --- | --- |
21+
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
22+
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) |
23+
| `--append` | `bool` | | Append inside the cell instead of replacing existing cell content |
24+
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
25+
| `--col` | `int` | | 1-based column number |
26+
| `--color` | `string` | auto | Color output: auto\|always\|never |
27+
| `--content` | `string` | | Replacement content (omit when using --content-file) |
28+
| `--content-file` | `string` | | Read replacement content from a file |
29+
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
30+
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
31+
| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) |
32+
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
33+
| `--format` | `string` | markdown | Content format: markdown\|plain |
34+
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
35+
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
36+
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
37+
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
38+
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
39+
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
40+
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
41+
| `--row` | `int` | | 1-based row number |
42+
| `--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. |
43+
| `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) |
44+
| `--table-index` | `int` | 1 | 1-based table index in document order; negative indexes count from the end |
45+
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
46+
| `--version` | `kong.VersionFlag` | | Print version and exit |
47+
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
48+
49+
## See Also
50+
51+
- [gog docs](gog-docs.md)
52+
- [Command index](README.md)

docs/commands/gog-docs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ gog docs (doc) <command> [flags]
1818

1919
- [gog docs add-tab](gog-docs-add-tab.md) - Add a tab to a Google Doc
2020
- [gog docs cat](gog-docs-cat.md) - Print a Google Doc as plain text
21+
- [gog docs cell-update](gog-docs-cell-update.md) - Replace or append content inside a specific table cell
2122
- [gog docs clear](gog-docs-clear.md) - Clear all content from a Google Doc
2223
- [gog docs comments](gog-docs-comments.md) - Manage comments on files
2324
- [gog docs copy](gog-docs-copy.md) - Copy a Google Doc

docs/docs-editing.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,20 @@ to insert at a specific document index. Prefer this primitive when you want a
118118
guaranteed native table rather than relying on the Markdown writer's table
119119
rendering (see `gog docs write --markdown`).
120120

121+
Update one existing table cell without round-tripping the surrounding document:
122+
123+
```bash
124+
gog docs cell-update <docId> --table-index 1 --row 2 --col 3 \
125+
--content "**Ready**" --format markdown
126+
```
127+
128+
Coordinates are 1-based. `--tab` targets a specific tab, and `--append` inserts
129+
at the end of the cell instead of replacing its current content.
130+
121131
Command page:
122132

123133
- [`gog docs insert-table`](commands/gog-docs-insert-table.md)
134+
- [`gog docs cell-update`](commands/gog-docs-cell-update.md)
124135

125136
## Tabs
126137

internal/cmd/docs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type DocsCmd struct {
3232
Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"`
3333
Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"`
3434
InsertTable DocsInsertTableCmd `cmd:"" name:"insert-table" help:"Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json"`
35+
CellUpdate DocsCellUpdateCmd `cmd:"" name:"cell-update" aliases:"update-cell" help:"Replace or append content inside a specific table cell"`
3536
InsertPageBreak DocsInsertPageBreakCmd `cmd:"" name:"insert-page-break" aliases:"page-break,pb" help:"Insert a page break at a specific position (or end-of-doc with --at-end)"`
3637
Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"`
3738
FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence."`

internal/cmd/docs_cell_update.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strings"
8+
9+
"google.golang.org/api/docs/v1"
10+
11+
"github.com/steipete/gogcli/internal/outfmt"
12+
"github.com/steipete/gogcli/internal/ui"
13+
)
14+
15+
type DocsCellUpdateCmd struct {
16+
DocID string `arg:"" name:"docId" help:"Doc ID"`
17+
TableIndex int `name:"table-index" help:"1-based table index in document order; negative indexes count from the end" default:"1"`
18+
Row int `name:"row" required:"" help:"1-based row number"`
19+
Col int `name:"col" required:"" help:"1-based column number"`
20+
Content string `name:"content" help:"Replacement content (omit when using --content-file)"`
21+
ContentFile string `name:"content-file" help:"Read replacement content from a file"`
22+
Format string `name:"format" help:"Content format: markdown|plain" default:"markdown" enum:"markdown,plain"`
23+
Append bool `name:"append" help:"Append inside the cell instead of replacing existing cell content"`
24+
Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"`
25+
TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"`
26+
}
27+
28+
func (c *DocsCellUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
29+
u := ui.FromContext(ctx)
30+
docID := strings.TrimSpace(c.DocID)
31+
if docID == "" {
32+
return usage("empty docId")
33+
}
34+
if c.TableIndex == 0 {
35+
return usage("--table-index cannot be 0")
36+
}
37+
if c.Row < 1 {
38+
return usage("--row must be >= 1")
39+
}
40+
if c.Col < 1 {
41+
return usage("--col must be >= 1")
42+
}
43+
content, err := c.resolveContent()
44+
if err != nil {
45+
return err
46+
}
47+
format := strings.ToLower(strings.TrimSpace(c.Format))
48+
if format == "" {
49+
format = docsContentFormatMarkdown
50+
}
51+
if format != docsContentFormatMarkdown && format != docsContentFormatPlain {
52+
return usage("--format must be markdown or plain")
53+
}
54+
tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID)
55+
if tabErr != nil {
56+
return tabErr
57+
}
58+
c.Tab = tab
59+
60+
if dryRunErr := dryRunExit(ctx, flags, "docs.cell-update", map[string]any{
61+
"document_id": docID,
62+
"table_index": c.TableIndex,
63+
"row": c.Row,
64+
"col": c.Col,
65+
"format": format,
66+
"append": c.Append,
67+
"tab": c.Tab,
68+
}); dryRunErr != nil {
69+
return dryRunErr
70+
}
71+
72+
svc, err := requireDocsService(ctx, flags)
73+
if err != nil {
74+
return err
75+
}
76+
loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab)
77+
if err != nil {
78+
return err
79+
}
80+
c.Tab = loaded.tabID
81+
82+
ref := &tableCellRef{tableIndex: c.TableIndex, row: c.Row, col: c.Col}
83+
cell, err := findTableCell(loaded.target, ref)
84+
if err != nil {
85+
return fmt.Errorf("find table cell: %w", err)
86+
}
87+
cellText, startIdx, endIdx := getCellText(cell)
88+
if startIdx <= 0 || endIdx <= startIdx {
89+
return fmt.Errorf("target cell has no editable text range")
90+
}
91+
writeEnd := endIdx
92+
if strings.HasSuffix(cellText, "\n") {
93+
writeEnd--
94+
}
95+
writeStart := startIdx
96+
prefixBoundary := false
97+
if c.Append {
98+
writeStart = writeEnd
99+
prefixBoundary = format == docsContentFormatMarkdown &&
100+
strings.TrimSpace(strings.TrimSuffix(cellText, "\n")) != "" &&
101+
markdownCellAppendNeedsBoundary(ParseMarkdown(content))
102+
}
103+
104+
if err := updateDocsCellContent(ctx, svc, loaded.full, writeStart, writeEnd, content, format, c.Append, prefixBoundary, c.Tab); err != nil {
105+
return err
106+
}
107+
108+
if outfmt.IsJSON(ctx) {
109+
payload := map[string]any{
110+
"documentId": docID,
111+
"tableIndex": c.TableIndex,
112+
"row": c.Row,
113+
"col": c.Col,
114+
"format": format,
115+
"append": c.Append,
116+
"updated": true,
117+
}
118+
if c.Tab != "" {
119+
payload["tabId"] = c.Tab
120+
}
121+
return outfmt.WriteJSON(ctx, os.Stdout, payload)
122+
}
123+
u.Out().Linef("documentId\t%s", docID)
124+
u.Out().Linef("table_index\t%d", c.TableIndex)
125+
u.Out().Linef("row\t%d", c.Row)
126+
u.Out().Linef("col\t%d", c.Col)
127+
u.Out().Linef("updated\ttrue")
128+
if c.Tab != "" {
129+
u.Out().Linef("tabId\t%s", c.Tab)
130+
}
131+
return nil
132+
}
133+
134+
func (c *DocsCellUpdateCmd) resolveContent() (string, error) {
135+
if c.ContentFile != "" && c.Content != "" {
136+
return "", usage("cannot use both --content and --content-file")
137+
}
138+
if c.ContentFile == "" {
139+
return c.Content, nil
140+
}
141+
data, err := os.ReadFile(c.ContentFile)
142+
if err != nil {
143+
return "", fmt.Errorf("read content file: %w", err)
144+
}
145+
return string(data), nil
146+
}
147+
148+
func updateDocsCellContent(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, content, format string, appendOnly bool, prefixBoundary bool, tabID string) error {
149+
var requests []*docs.Request
150+
if !appendOnly && startIdx < endIdx {
151+
requests = append(requests, &docs.Request{
152+
DeleteContentRange: &docs.DeleteContentRangeRequest{
153+
Range: &docs.Range{StartIndex: startIdx, EndIndex: endIdx, TabId: tabID},
154+
},
155+
})
156+
}
157+
if content != "" {
158+
if format == docsContentFormatMarkdown {
159+
baseIndex := startIdx
160+
prefix := ""
161+
if prefixBoundary {
162+
baseIndex++
163+
prefix = "\n"
164+
}
165+
formatReqs, textToInsert, tables := MarkdownToDocsRequests(ParseMarkdown(content), baseIndex, tabID)
166+
if len(tables) > 0 {
167+
return usage("markdown tables are not supported inside table cells")
168+
}
169+
textToInsert = strings.TrimSuffix(textToInsert, "\n")
170+
formatReqs = clampDocsCellFormatRequests(formatReqs, baseIndex+utf16Len(textToInsert))
171+
if textToInsert != "" {
172+
requests = append(requests, &docs.Request{
173+
InsertText: &docs.InsertTextRequest{
174+
Location: &docs.Location{Index: startIdx, TabId: tabID},
175+
Text: prefix + textToInsert,
176+
},
177+
})
178+
requests = append(requests, formatReqs...)
179+
}
180+
} else {
181+
requests = append(requests, &docs.Request{
182+
InsertText: &docs.InsertTextRequest{
183+
Location: &docs.Location{Index: startIdx, TabId: tabID},
184+
Text: content,
185+
},
186+
})
187+
}
188+
}
189+
if len(requests) == 0 {
190+
return nil
191+
}
192+
_, err := svc.Documents.BatchUpdate(doc.DocumentId, &docs.BatchUpdateDocumentRequest{
193+
WriteControl: &docs.WriteControl{RequiredRevisionId: doc.RevisionId},
194+
Requests: requests,
195+
}).Context(ctx).Do()
196+
if err != nil {
197+
return fmt.Errorf("cell update: %w", err)
198+
}
199+
return nil
200+
}
201+
202+
func markdownCellAppendNeedsBoundary(elements []MarkdownElement) bool {
203+
if len(elements) != 1 {
204+
return len(elements) > 1
205+
}
206+
switch elements[0].Type {
207+
case MDText, MDParagraph:
208+
return false
209+
default:
210+
return true
211+
}
212+
}
213+
214+
func clampDocsCellFormatRequests(requests []*docs.Request, maxEnd int64) []*docs.Request {
215+
out := requests[:0]
216+
for _, req := range requests {
217+
r := docsRequestRange(req)
218+
if r == nil {
219+
out = append(out, req)
220+
continue
221+
}
222+
if r.EndIndex > maxEnd {
223+
r.EndIndex = maxEnd
224+
}
225+
if r.StartIndex >= r.EndIndex {
226+
continue
227+
}
228+
out = append(out, req)
229+
}
230+
return out
231+
}
232+
233+
func docsRequestRange(req *docs.Request) *docs.Range {
234+
switch {
235+
case req.UpdateTextStyle != nil:
236+
return req.UpdateTextStyle.Range
237+
case req.UpdateParagraphStyle != nil:
238+
return req.UpdateParagraphStyle.Range
239+
case req.CreateParagraphBullets != nil:
240+
return req.CreateParagraphBullets.Range
241+
case req.DeleteParagraphBullets != nil:
242+
return req.DeleteParagraphBullets.Range
243+
default:
244+
return nil
245+
}
246+
}

0 commit comments

Comments
 (0)