From 8e893a85fd1256328faa0f7d19b4566d5c7329dc Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:26:29 -0400 Subject: [PATCH 1/4] feat(docs): add insert-table primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #602. Surfaces the Docs API InsertTableRequest as a standalone CLI subcommand that bypasses the markdown writer (#592 / #608 / #609 are all markdown-table-only bugs; agents need a path that doesn't go through that converter for table-shaped deliverables). Usage: gog docs insert-table --rows N --cols M [--index N | --at-end] \ [--values-json [[...]]] [--tab=STRING] Internals: - Reuses the existing TableInserter.InsertNativeTable so the structural insert, the post-insert Get-to-find-cell-indices, and the per-cell text inserts all go through the same path the markdown writer uses. - --values-json takes a JSON 2D string array whose dimensions must match --rows x --cols exactly. Empty values-json fills the table with empty cells. - --index / --at-end semantics match the new insert-page-break command and docs write --append: omit either → resolve to end-of-doc via docsTargetEndIndexAndTabID; --at-end is the explicit form. Tests cover the parseTableValuesJSON helper (empty, populated, row mismatch, column mismatch, malformed JSON) plus a table-driven Run- level check for every flag-validation branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cmd/docs.go | 1 + internal/cmd/docs_insert_table.go | 155 +++++++++++++++++++++++++ internal/cmd/docs_insert_table_test.go | 75 ++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 internal/cmd/docs_insert_table.go create mode 100644 internal/cmd/docs_insert_table_test.go diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index a18d7268..a4401298 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -31,6 +31,7 @@ type DocsCmd struct { ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` Insert DocsInsertCmd `cmd:"" name:"insert" help:"Insert text at a specific position"` + 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"` 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)"` Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text. Supports plain text or markdown with images; use --first for a single occurrence."` diff --git a/internal/cmd/docs_insert_table.go b/internal/cmd/docs_insert_table.go new file mode 100644 index 00000000..1d3ae647 --- /dev/null +++ b/internal/cmd/docs_insert_table.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// DocsInsertTableCmd inserts a native Google Docs table at a specific +// character index (or at end-of-doc) and optionally populates it from a JSON +// 2D string array. The markdown writer can already render tables, but it +// drops them mid-insert in some scenarios — see #592/#607/#608/#609 — and +// agents needed a path that bypasses the markdown converter entirely (#602). +type DocsInsertTableCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Rows int `name:"rows" required:"" help:"Number of rows (>=1)"` + Cols int `name:"cols" required:"" help:"Number of columns (>=1)"` + Index int64 `name:"index" help:"Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc."` + AtEnd bool `name:"at-end" help:"Insert at end-of-doc/tab (mutually exclusive with --index)"` + ValuesJSON string `name:"values-json" help:"Cell values as a JSON 2D string array; dimensions must match --rows x --cols when supplied"` + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` +} + +func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := strings.TrimSpace(c.DocID) + if docID == "" { + return usage("empty docId") + } + if c.Rows < 1 { + return usage("--rows must be >= 1") + } + if c.Cols < 1 { + return usage("--cols must be >= 1") + } + if c.AtEnd && c.Index != 0 { + return usage("--at-end and --index are mutually exclusive") + } + if c.Index < 0 { + return usage("--index must be >= 1 (index 0 is reserved)") + } + + cells, err := parseTableValuesJSON(c.ValuesJSON, c.Rows, c.Cols) + if err != nil { + return err + } + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + c.Tab = tab + + resolveEnd := c.AtEnd || c.Index == 0 + + dryRunPayload := map[string]any{ + "documentId": docID, + "rows": c.Rows, + "cols": c.Cols, + "tab": c.Tab, + } + if resolveEnd { + dryRunPayload["atIndex"] = "end" + } else { + dryRunPayload["atIndex"] = c.Index + } + if dryRunErr := dryRunExit(ctx, flags, "docs.insert-table", dryRunPayload); dryRunErr != nil { + return dryRunErr + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + insertIndex := c.Index + if resolveEnd { + endIndex, tabID, endErr := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) + if endErr != nil { + return endErr + } + c.Tab = tabID + insertIndex = docsAppendIndex(endIndex) + } else if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr + } + c.Tab = tabID + } + + inserter := NewTableInserter(svc, docID) + tableEnd, err := inserter.InsertNativeTable(ctx, insertIndex, cells, c.Tab) + if err != nil { + return fmt.Errorf("insert table: %w", err) + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "atIndex": insertIndex, + "rows": c.Rows, + "cols": c.Cols, + "tableEnd": tableEnd, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + u.Out().Linef("documentId\t%s", docID) + u.Out().Linef("atIndex\t%d", insertIndex) + u.Out().Linef("rows\t%d", c.Rows) + u.Out().Linef("cols\t%d", c.Cols) + u.Out().Linef("tableEnd\t%d", tableEnd) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + return nil +} + +// parseTableValuesJSON converts a JSON 2D string array into a rows x cols cell +// matrix. When the input is empty, returns an all-empty rows x cols matrix +// suitable for inserting an empty table structure. Validates that the supplied +// JSON exactly matches the requested dimensions. +func parseTableValuesJSON(raw string, rows, cols int) ([][]string, error) { + if strings.TrimSpace(raw) == "" { + cells := make([][]string, rows) + for i := range cells { + cells[i] = make([]string, cols) + } + return cells, nil + } + + var parsed [][]string + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, usagef("--values-json must be a JSON 2D string array: %v", err) + } + if len(parsed) != rows { + return nil, usagef("--values-json row count %d does not match --rows %d", len(parsed), rows) + } + for i, row := range parsed { + if len(row) != cols { + return nil, usagef("--values-json row %d has %d columns, want %d", i, len(row), cols) + } + } + return parsed, nil +} diff --git a/internal/cmd/docs_insert_table_test.go b/internal/cmd/docs_insert_table_test.go new file mode 100644 index 00000000..a373100b --- /dev/null +++ b/internal/cmd/docs_insert_table_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseTableValuesJSON_EmptyProducesAllEmptyMatrix(t *testing.T) { + got, err := parseTableValuesJSON("", 2, 3) + if err != nil { + t.Fatalf("err: %v", err) + } + want := [][]string{{"", "", ""}, {"", "", ""}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestParseTableValuesJSON_PopulatedMatrixRoundTrip(t *testing.T) { + got, err := parseTableValuesJSON(`[["a","b","c"],["d","e","f"]]`, 2, 3) + if err != nil { + t.Fatalf("err: %v", err) + } + want := [][]string{{"a", "b", "c"}, {"d", "e", "f"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestParseTableValuesJSON_RowCountMismatch(t *testing.T) { + _, err := parseTableValuesJSON(`[["a","b"]]`, 2, 2) + if err == nil || !strings.Contains(err.Error(), "row count 1 does not match --rows 2") { + t.Fatalf("expected row-count error, got %v", err) + } +} + +func TestParseTableValuesJSON_ColumnCountMismatch(t *testing.T) { + _, err := parseTableValuesJSON(`[["a","b"],["c"]]`, 2, 2) + if err == nil || !strings.Contains(err.Error(), "row 1 has 1 columns, want 2") { + t.Fatalf("expected column-count error, got %v", err) + } +} + +func TestParseTableValuesJSON_InvalidJSONRejected(t *testing.T) { + _, err := parseTableValuesJSON(`[not json]`, 1, 1) + if err == nil || !strings.Contains(err.Error(), "--values-json must be a JSON 2D string array") { + t.Fatalf("expected JSON parse error, got %v", err) + } +} + +func TestDocsInsertTableCmd_FlagValidation(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newDocsCmdContext(t) + + cases := []struct { + name string + args []string + want string + }{ + {"rows < 1", []string{"doc1", "--rows", "0", "--cols", "2"}, "--rows must be >= 1"}, + {"cols < 1", []string{"doc1", "--rows", "2", "--cols", "0"}, "--cols must be >= 1"}, + {"index + at-end conflict", []string{"doc1", "--rows", "2", "--cols", "2", "--index", "5", "--at-end"}, "mutually exclusive"}, + {"negative index", []string{"doc1", "--rows", "2", "--cols", "2", "--index=-1"}, "--index must be >= 1"}, + {"values-json dims wrong", []string{"doc1", "--rows", "2", "--cols", "2", "--values-json", `[["a","b"]]`}, "row count 1 does not match --rows 2"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + err := runKong(t, &DocsInsertTableCmd{}, tt.args, ctx, flags) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected error containing %q, got %v", tt.want, err) + } + }) + } +} From 6a71ad2655d02e03601ffe4d809e1b74141fec32 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:40:16 -0400 Subject: [PATCH 2/4] docs: changelog + regen + topic-doc for #602 insert-table Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + README.md | 1 + docs/commands.generated.md | 1 + docs/commands/README.md | 3 +- docs/commands/gog-docs-insert-table.md | 49 ++++++++++++++++++++++++++ docs/commands/gog-docs.md | 1 + docs/docs-editing.md | 22 ++++++++++++ 7 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/commands/gog-docs-insert-table.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa1c366..bab9b35c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Docs: add `gog docs insert-page-break [--index N | --at-end] [--tab=STRING]` to insert a Google Docs page break directly via `InsertPageBreakRequest` — markdown has no native page-break construct, so this is the only path for multi-page deliverables. Aliases: `page-break`, `pb`. (#604) - Docs: add `--heading-level N` (1..6 shortcut) and `--named-style NAME` (full enum) to `gog docs format` so existing paragraphs can be promoted to `HEADING_1`..`HEADING_6`, `TITLE`, `SUBTITLE`, or `NORMAL_TEXT`. Both set `paragraphStyle.namedStyleType` on the existing UpdateParagraphStyle request and compose cleanly with `--alignment` / `--line-spacing`. (#605) - Sheets: add `gog sheets reorder-tab --tab= --to=N` to move a tab to a specific 0-based position via `updateSheetProperties` with field mask `index`. `--tab` accepts a title or a numeric sheet ID; `--to=0` is force-sent so the leftmost target reaches the wire as `"index":0`. Aliases: `move-tab`, `reorder-sheet`, `move-sheet`. (#603) +- Docs: add `gog docs insert-table --rows N --cols M [--index N | --at-end] [--values-json [[...]]] [--tab=STRING]` to insert a native Google Docs table directly via `InsertTableRequest`, bypassing the markdown writer. `--values-json` takes a JSON 2D string array whose dimensions must match `--rows`x`--cols`. Empty `--values-json` produces an empty table structure. (#602) ### Fixed diff --git a/README.md b/README.md index cb8781a3..966c1603 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,7 @@ Docs: [Google Docs editing](docs/docs-editing.md), gog docs write --append --markdown --text '## Status' gog docs format --match Status --bold --font-size 18 gog docs insert-page-break --at-end +gog docs insert-table --rows 3 --cols 2 --at-end gog docs add-tab --title "Notes" gog docs tabs add --title "Notes" gog docs find-replace old new --tab "Notes" --dry-run diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 333e63c9..36e7d881 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -239,6 +239,7 @@ Generated from `gog schema --json`. - [`gog docs (doc) info (get,show) `](commands/gog-docs-info.md) - Get Google Doc metadata - [`gog docs (doc) insert [] [flags]`](commands/gog-docs-insert.md) - Insert text at a specific position - [`gog docs (doc) insert-page-break (page-break,pb) [flags]`](commands/gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) + - [`gog docs (doc) insert-table --rows=INT --cols=INT [flags]`](commands/gog-docs-insert-table.md) - Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json - [`gog docs (doc) list-tabs `](commands/gog-docs-list-tabs.md) - List all tabs in a Google Doc - [`gog docs (doc) raw [flags]`](commands/gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [`gog docs (doc) rename-tab [flags]`](commands/gog-docs-rename-tab.md) - Rename a tab in a Google Doc diff --git a/docs/commands/README.md b/docs/commands/README.md index 07fe2c20..fec98790 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ 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. -Generated pages: 566. +Generated pages: 567. ## Top-level Commands @@ -290,6 +290,7 @@ Generated pages: 566. - [gog docs info](gog-docs-info.md) - Get Google Doc metadata - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs insert-page-break](gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) + - [gog docs insert-table](gog-docs-insert-table.md) - Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc - [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc diff --git a/docs/commands/gog-docs-insert-table.md b/docs/commands/gog-docs-insert-table.md new file mode 100644 index 00000000..2da57392 --- /dev/null +++ b/docs/commands/gog-docs-insert-table.md @@ -0,0 +1,49 @@ +# `gog docs insert-table` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json + +## Usage + +```bash +gog docs (doc) insert-table --rows=INT --cols=INT [flags] +``` + +## Parent + +- [gog docs](gog-docs.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--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) | +| `--at-end` | `bool` | | Insert at end-of-doc/tab (mutually exclusive with --index) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--cols` | `int` | | Number of columns (>=1) | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--index` | `int64` | | Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--rows` | `int` | | Number of rows (>=1) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--tab` | `string` | | Target a specific tab by title or ID (see docs list-tabs) | +| `--values-json` | `string` | | Cell values as a JSON 2D string array; dimensions must match --rows x --cols when supplied | +| `-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 | + +## See Also + +- [gog docs](gog-docs.md) +- [Command index](README.md) diff --git a/docs/commands/gog-docs.md b/docs/commands/gog-docs.md index 7fa8b6b5..88ccebb5 100644 --- a/docs/commands/gog-docs.md +++ b/docs/commands/gog-docs.md @@ -31,6 +31,7 @@ gog docs (doc) [flags] - [gog docs info](gog-docs-info.md) - Get Google Doc metadata - [gog docs insert](gog-docs-insert.md) - Insert text at a specific position - [gog docs insert-page-break](gog-docs-insert-page-break.md) - Insert a page break at a specific position (or end-of-doc with --at-end) +- [gog docs insert-table](gog-docs-insert-table.md) - Insert a native table at a specific position (or end-of-doc with --at-end), optionally populated via --values-json - [gog docs list-tabs](gog-docs-list-tabs.md) - List all tabs in a Google Doc - [gog docs raw](gog-docs-raw.md) - Dump raw Google Docs API response as JSON (Documents.Get; lossless; for scripting and LLM consumption) - [gog docs rename-tab](gog-docs-rename-tab.md) - Rename a tab in a Google Doc diff --git a/docs/docs-editing.md b/docs/docs-editing.md index ffa7913d..74da0d66 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -72,6 +72,28 @@ Command page: - [`gog docs insert-page-break`](commands/gog-docs-insert-page-break.md) +## Tables + +Insert a native Google Docs table directly via the Docs API, bypassing the +Markdown writer: + +```bash +gog docs insert-table --rows 3 --cols 2 --at-end +gog docs insert-table --rows 2 --cols 2 --index 1 \ + --values-json '[["A","B"],["C","D"]]' +``` + +`--values-json` takes a JSON 2D string array whose dimensions must match +`--rows`x`--cols`; omit it to insert an empty table structure. Use `--at-end` +to append at the end of the document (or the selected `--tab`), or `--index N` +to insert at a specific document index. Prefer this primitive when you want a +guaranteed native table rather than relying on the Markdown writer's table +rendering (see `gog docs write --markdown`). + +Command page: + +- [`gog docs insert-table`](commands/gog-docs-insert-table.md) + ## Tabs Manage Google Docs tabs: From 0d93de61ddc45bb5c061877672e55a989e427c4c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 22:19:31 +0100 Subject: [PATCH 3/4] fix(docs): harden insert table indexing --- docs/commands/gog-docs-insert-table.md | 2 +- internal/cmd/docs_insert_table.go | 25 ++++++++------- internal/cmd/docs_insert_table_test.go | 1 + internal/cmd/docs_table_inserter.go | 28 +++++++++++++---- .../cmd/docs_table_inserter_inline_test.go | 31 ++++++++++++++++--- 5 files changed, 64 insertions(+), 23 deletions(-) diff --git a/docs/commands/gog-docs-insert-table.md b/docs/commands/gog-docs-insert-table.md index 2da57392..03b0ffa9 100644 --- a/docs/commands/gog-docs-insert-table.md +++ b/docs/commands/gog-docs-insert-table.md @@ -30,7 +30,7 @@ gog docs (doc) insert-table --rows=INT --cols=INT [flags] | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | | `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | -| `--index` | `int64` | | Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc. | +| `--index` | `*int64` | | Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc. | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | diff --git a/internal/cmd/docs_insert_table.go b/internal/cmd/docs_insert_table.go index 1d3ae647..68752d35 100644 --- a/internal/cmd/docs_insert_table.go +++ b/internal/cmd/docs_insert_table.go @@ -20,7 +20,7 @@ type DocsInsertTableCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Rows int `name:"rows" required:"" help:"Number of rows (>=1)"` Cols int `name:"cols" required:"" help:"Number of columns (>=1)"` - Index int64 `name:"index" help:"Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc."` + Index *int64 `name:"index" help:"Character index to insert at (1 = beginning). Omit or use --at-end for end-of-doc."` AtEnd bool `name:"at-end" help:"Insert at end-of-doc/tab (mutually exclusive with --index)"` ValuesJSON string `name:"values-json" help:"Cell values as a JSON 2D string array; dimensions must match --rows x --cols when supplied"` Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` @@ -39,10 +39,10 @@ func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { if c.Cols < 1 { return usage("--cols must be >= 1") } - if c.AtEnd && c.Index != 0 { + if c.AtEnd && c.Index != nil { return usage("--at-end and --index are mutually exclusive") } - if c.Index < 0 { + if c.Index != nil && *c.Index < 1 { return usage("--index must be >= 1 (index 0 is reserved)") } @@ -57,7 +57,7 @@ func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { } c.Tab = tab - resolveEnd := c.AtEnd || c.Index == 0 + resolveEnd := c.AtEnd || c.Index == nil dryRunPayload := map[string]any{ "documentId": docID, @@ -68,7 +68,7 @@ func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { if resolveEnd { dryRunPayload["atIndex"] = "end" } else { - dryRunPayload["atIndex"] = c.Index + dryRunPayload["atIndex"] = *c.Index } if dryRunErr := dryRunExit(ctx, flags, "docs.insert-table", dryRunPayload); dryRunErr != nil { return dryRunErr @@ -79,7 +79,7 @@ func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - insertIndex := c.Index + var insertIndex int64 if resolveEnd { endIndex, tabID, endErr := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) if endErr != nil { @@ -87,12 +87,15 @@ func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { } c.Tab = tabID insertIndex = docsAppendIndex(endIndex) - } else if c.Tab != "" { - tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) - if tabErr != nil { - return tabErr + } else { + insertIndex = *c.Index + if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr + } + c.Tab = tabID } - c.Tab = tabID } inserter := NewTableInserter(svc, docID) diff --git a/internal/cmd/docs_insert_table_test.go b/internal/cmd/docs_insert_table_test.go index a373100b..1d3c0328 100644 --- a/internal/cmd/docs_insert_table_test.go +++ b/internal/cmd/docs_insert_table_test.go @@ -61,6 +61,7 @@ func TestDocsInsertTableCmd_FlagValidation(t *testing.T) { {"rows < 1", []string{"doc1", "--rows", "0", "--cols", "2"}, "--rows must be >= 1"}, {"cols < 1", []string{"doc1", "--rows", "2", "--cols", "0"}, "--cols must be >= 1"}, {"index + at-end conflict", []string{"doc1", "--rows", "2", "--cols", "2", "--index", "5", "--at-end"}, "mutually exclusive"}, + {"zero index", []string{"doc1", "--rows", "2", "--cols", "2", "--index", "0"}, "--index must be >= 1"}, {"negative index", []string{"doc1", "--rows", "2", "--cols", "2", "--index=-1"}, "--index must be >= 1"}, {"values-json dims wrong", []string{"doc1", "--rows", "2", "--cols", "2", "--values-json", `[["a","b"]]`}, "row count 1 does not match --rows 2"}, } diff --git a/internal/cmd/docs_table_inserter.go b/internal/cmd/docs_table_inserter.go index 12e19535..3f26ed29 100644 --- a/internal/cmd/docs_table_inserter.go +++ b/internal/cmd/docs_table_inserter.go @@ -50,13 +50,28 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64 } // Step 2: Fetch the document to get cell indices - doc, err := ti.svc.Documents.Get(ti.docID).Context(ctx).Do() + getCall := ti.svc.Documents.Get(ti.docID).Context(ctx) + if tabID != "" { + getCall = getCall.IncludeTabsContent(true) + } + doc, err := getCall.Do() if err != nil { return tableIndex, fmt.Errorf("get document after table insert: %w", err) } + targetDoc := doc + if tabID != "" { + tab, tabErr := findTab(flattenTabs(doc.Tabs), tabID) + if tabErr != nil { + return tableIndex, tabErr + } + if tab.DocumentTab == nil || tab.DocumentTab.Body == nil { + return tableIndex, fmt.Errorf("tab has no document body: %s", tabID) + } + targetDoc = &docs.Document{Body: tab.DocumentTab.Body} + } // Step 3: Find the table in the document and get cell indices - cellIndices, tableEndIndex, err := ti.getTableCellIndices(doc, tableIndex, rows, cols) + cellIndices, tableEndIndex, err := ti.getTableCellIndices(targetDoc, tableIndex, rows, cols) if err != nil { return tableEndIndex, err } @@ -74,7 +89,7 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64 continue } - requests, insertedLen := buildTableCellRequests(cellContent, cellIdx, rowIdx == 0) + requests, insertedLen := buildTableCellRequests(cellContent, cellIdx, rowIdx == 0, tabID) if len(requests) == 0 { continue } @@ -101,7 +116,7 @@ func (ti *TableInserter) InsertNativeTable(ctx context.Context, tableIndex int64 // the UTF-16 length of the text that will be inserted so callers can keep // running cell indices in sync. If the cell content strips to an empty string // (e.g. content was only markers), returns (nil, 0). -func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool) ([]*docs.Request, int64) { +func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool, tabID string) ([]*docs.Request, int64) { styles, stripped := ParseInlineFormatting(cellContent) if stripped == "" { return nil, 0 @@ -110,7 +125,7 @@ func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool) insertedLen := utf16Len(stripped) requests := []*docs.Request{{ InsertText: &docs.InsertTextRequest{ - Location: &docs.Location{Index: cellIdx}, + Location: &docs.Location{Index: cellIdx, TabId: tabID}, Text: stripped, }, }} @@ -121,6 +136,7 @@ func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool) Range: &docs.Range{ StartIndex: cellIdx, EndIndex: cellIdx + insertedLen, + TabId: tabID, }, TextStyle: &docs.TextStyle{Bold: true}, Fields: "bold", @@ -129,7 +145,7 @@ func buildTableCellRequests(cellContent string, cellIdx int64, isHeaderRow bool) } for _, style := range styles { - if req := buildTextStyleRequest(style, cellIdx, ""); req != nil { + if req := buildTextStyleRequest(style, cellIdx, tabID); req != nil { requests = append(requests, req) } } diff --git a/internal/cmd/docs_table_inserter_inline_test.go b/internal/cmd/docs_table_inserter_inline_test.go index c023ac5f..c72e69a9 100644 --- a/internal/cmd/docs_table_inserter_inline_test.go +++ b/internal/cmd/docs_table_inserter_inline_test.go @@ -12,7 +12,7 @@ import ( // pass used by paragraphs/headings. func TestBuildTableCellRequests_AppliesInlineBold(t *testing.T) { - reqs, inserted := buildTableCellRequests("**Alice**", 100, false) + reqs, inserted := buildTableCellRequests("**Alice**", 100, false, "") if inserted != utf16Len("Alice") { t.Fatalf("expected inserted len = utf16Len(\"Alice\") = %d, got %d", utf16Len("Alice"), inserted) @@ -53,7 +53,7 @@ func TestBuildTableCellRequests_AppliesInlineItalicAndCode(t *testing.T) { } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - reqs, inserted := buildTableCellRequests(tt.cell, 50, false) + reqs, inserted := buildTableCellRequests(tt.cell, 50, false, "") if inserted != utf16Len(tt.wantText) { t.Fatalf("inserted = %d, want %d", inserted, utf16Len(tt.wantText)) } @@ -78,7 +78,7 @@ func TestBuildTableCellRequests_AppliesInlineItalicAndCode(t *testing.T) { } func TestBuildTableCellRequests_HeaderRowAppliesBoldOverWholeCell(t *testing.T) { - reqs, inserted := buildTableCellRequests("Field", 10, true) + reqs, inserted := buildTableCellRequests("Field", 10, true, "") if inserted != utf16Len("Field") { t.Fatalf("inserted = %d, want %d", inserted, utf16Len("Field")) @@ -95,8 +95,29 @@ func TestBuildTableCellRequests_HeaderRowAppliesBoldOverWholeCell(t *testing.T) } } +func TestBuildTableCellRequests_IncludesTabID(t *testing.T) { + reqs, inserted := buildTableCellRequests("**Field**", 10, true, "t.second") + if inserted != utf16Len("Field") { + t.Fatalf("inserted = %d, want %d", inserted, utf16Len("Field")) + } + if len(reqs) != 3 { + t.Fatalf("expected 3 requests (insert + header bold + inline bold), got %d: %#v", len(reqs), reqs) + } + if got := reqs[0].InsertText.Location; got == nil || got.TabId != "t.second" { + t.Fatalf("expected insert tab ID, got %#v", got) + } + for i, req := range reqs[1:] { + if req.UpdateTextStyle == nil || req.UpdateTextStyle.Range == nil { + t.Fatalf("request %d: expected UpdateTextStyle range, got %#v", i+1, req) + } + if req.UpdateTextStyle.Range.TabId != "t.second" { + t.Fatalf("request %d: expected tab ID t.second, got %#v", i+1, req.UpdateTextStyle.Range) + } + } +} + func TestBuildTableCellRequests_PlainTextNoStyleRequest(t *testing.T) { - reqs, inserted := buildTableCellRequests("plain text", 1, false) + reqs, inserted := buildTableCellRequests("plain text", 1, false, "") if inserted != utf16Len("plain text") { t.Fatalf("inserted = %d, want %d", inserted, utf16Len("plain text")) } @@ -110,7 +131,7 @@ func TestBuildTableCellRequests_PlainTextNoStyleRequest(t *testing.T) { func TestBuildTableCellRequests_EmptyAfterStrippingReturnsNothing(t *testing.T) { // A cell whose entire content is markers (e.g. "**") would strip to "". - reqs, inserted := buildTableCellRequests("", 1, false) + reqs, inserted := buildTableCellRequests("", 1, false, "") if len(reqs) != 0 || inserted != 0 { t.Fatalf("expected (nil, 0) for empty cell, got reqs=%#v inserted=%d", reqs, inserted) } From b4dc4f3275dfd3df3e7ac894174fc6b8222ae8ac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 22:21:31 +0100 Subject: [PATCH 4/4] fix(docs): name end-of-doc sentinel --- internal/cmd/docs_edit.go | 4 ++-- internal/cmd/docs_helpers.go | 2 ++ internal/cmd/docs_insert_page_break.go | 2 +- internal/cmd/docs_insert_table.go | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 2bd8ed46..52cf19a8 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -400,7 +400,7 @@ func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *Root } c.Tab = tab - var index any = "end" + var index any = docsAtIndexEnd if c.Index > 0 { index = c.Index } @@ -522,7 +522,7 @@ func (c *DocsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { if c.Index != nil { dryRunPayload["atIndex"] = *c.Index } else { - dryRunPayload["atIndex"] = "end" + dryRunPayload["atIndex"] = docsAtIndexEnd } if dryRunErr := dryRunExit(ctx, flags, "docs.insert", dryRunPayload); dryRunErr != nil { return dryRunErr diff --git a/internal/cmd/docs_helpers.go b/internal/cmd/docs_helpers.go index 3cf6581a..d9745fa2 100644 --- a/internal/cmd/docs_helpers.go +++ b/internal/cmd/docs_helpers.go @@ -16,6 +16,8 @@ import ( "github.com/steipete/gogcli/internal/config" ) +const docsAtIndexEnd = "end" + func resolveContentInput(content, filePath string) (string, error) { if content != "" { return content, nil diff --git a/internal/cmd/docs_insert_page_break.go b/internal/cmd/docs_insert_page_break.go index 35071b93..a05e9e33 100644 --- a/internal/cmd/docs_insert_page_break.go +++ b/internal/cmd/docs_insert_page_break.go @@ -51,7 +51,7 @@ func (c *DocsInsertPageBreakCmd) Run(ctx context.Context, flags *RootFlags) erro "tab": c.Tab, } if resolveEnd { - dryRunPayload["atIndex"] = "end" + dryRunPayload["atIndex"] = docsAtIndexEnd } else { dryRunPayload["atIndex"] = *c.Index } diff --git a/internal/cmd/docs_insert_table.go b/internal/cmd/docs_insert_table.go index 68752d35..f5c46da1 100644 --- a/internal/cmd/docs_insert_table.go +++ b/internal/cmd/docs_insert_table.go @@ -66,7 +66,7 @@ func (c *DocsInsertTableCmd) Run(ctx context.Context, flags *RootFlags) error { "tab": c.Tab, } if resolveEnd { - dryRunPayload["atIndex"] = "end" + dryRunPayload["atIndex"] = docsAtIndexEnd } else { dryRunPayload["atIndex"] = *c.Index }