diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e49928d..1a48ba10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Raw API dumps: add `docs raw`, `sheets raw`, `slides raw`, `drive raw`, `gmail raw`, `calendar raw`, `people raw`, `contacts raw`, `tasks raw`, and `forms raw` subcommands for lossless Google API JSON output, with `--pretty`, Drive raw redaction defaults, Sheets grid-data warnings, and a raw-output security audit. (#495, #496) — thanks @karbassi. - Docs: add `docs format` and plain-text `docs write` formatting flags for fonts, colors, bold/italic/underline/strikethrough, alignment, and line spacing. (#479) — thanks @mmaghsoodnia. - Drive: add `--fields` to `drive ls` and `drive get` so callers can pass Drive API field masks for fields beyond the default JSON set. (#495) — thanks @karbassi. +- Sheets: add `sheets table` list/get/create/delete commands for Google Sheets structured tables. (#470) — thanks @Pedrohgv. - Agent safety: add baked safety-profile builds for fail-closed agent binaries, with `agent-safe`, `readonly`, and `full` profiles, filtered help/schema output, docs, and build tooling. (#366, #239) — thanks @drewburchfield. - Calendar: add `--with-meet` to `calendar update` for adding Google Meet conferencing to existing events. (#538) — thanks @alexisperumal. - Calendar: add `calendar move` / `calendar transfer` to move an event to another calendar and change its organizer. (#448) — thanks @markusbkoch. diff --git a/README.md b/README.md index c347d215..da7b498b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Drive** - list/search/upload/download files, scope search to folders or shared drives, replace uploads in-place, convert uploads (including Markdown to Google Doc), manage permissions/comments, organize folders, and list shared drives - **Contacts** - search/create/update contacts, including addresses, relations, org/title metadata, custom fields, Workspace directory, and other contacts - **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, plus repeat schedule materialization with RRULE aliases -- **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs and named ranges, format/merge/freeze/resize cells, read/write notes, inspect formats, find/replace text, list links, and create/export sheets +- **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs, named ranges, and Sheets tables, format/merge/freeze/resize cells, read/write notes, inspect formats, find/replace text, list links, and create/export sheets - **Forms** - create/update forms, manage questions, inspect responses, and manage watches - **Apps Script** - create/get/bind projects, inspect content, and run functions - **Docs/Slides** - create/copy/export docs/slides, edit Docs by tab title or ID, import Markdown, do richer find-replace, export whole Docs or a single Docs tab, and generate Slides from Markdown or templates @@ -1388,6 +1388,13 @@ gog sheets named-ranges add MyCols 'Sheet1!A:C' gog sheets named-ranges update MyNamedRange --name MyNamedRange2 gog sheets named-ranges delete MyNamedRange2 +# Tables +gog sheets table list +gog sheets table create 'Sheet1!A1:C4' --name Tasks --columns-json '[{"columnName":"Task","columnType":"TEXT"},{"columnName":"Amount","columnType":"DOUBLE"},{"columnName":"Done","columnType":"BOOLEAN"}]' +gog sheets table get +gog sheets table delete --force +# See docs/sheets-tables.md for valid column types and current command scope. + # Charts gog sheets chart list gog sheets chart get --json > chart.json diff --git a/docs/README.md b/docs/README.md index 4f9185ad..49faed16 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ Keep, and related agent workflows. accounts, or Workspace domain-wide delegation. - Read [Command Guards and Baked Safety Profiles](safety-profiles.md) when running `gog` from agents or automation. +- Read [Sheets Tables](sheets-tables.md) when creating or inspecting Google + Sheets structured tables. - Open the [Command Index](commands/README.md) for generated docs for every CLI command. diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 1a57f6f2..879e7b69 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -414,6 +414,11 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) rename-tab (rename-sheet) `](commands/gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet - [`gog sheets (sheet) resize-columns [flags]`](commands/gog-sheets-resize-columns.md) - Resize sheet columns - [`gog sheets (sheet) resize-rows [flags]`](commands/gog-sheets-resize-rows.md) - Resize sheet rows + - [`gog sheets (sheet) table (tables) `](commands/gog-sheets-table.md) - Manage Google Sheets tables + - [`gog sheets (sheet) table (tables) create (add,new) --name=STRING --columns-json=STRING `](commands/gog-sheets-table-create.md) - Create a table + - [`gog sheets (sheet) table (tables) delete (rm,remove,del) `](commands/gog-sheets-table-delete.md) - Delete a table + - [`gog sheets (sheet) table (tables) get (show,info) `](commands/gog-sheets-table-get.md) - Get a table + - [`gog sheets (sheet) table (tables) list `](commands/gog-sheets-table-list.md) - List tables in a spreadsheet - [`gog sheets (sheet) unmerge `](commands/gog-sheets-unmerge.md) - Unmerge cells in a range - [`gog sheets (sheet) update (edit,set) [ ...] [flags]`](commands/gog-sheets-update.md) - Update values in a range - [`gog sheets (sheet) update-note (set-note) [flags]`](commands/gog-sheets-update-note.md) - Set or clear a cell note diff --git a/docs/commands/README.md b/docs/commands/README.md index db2dee63..63fefd53 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: 451. +Generated pages: 456. ## Top-level Commands @@ -457,6 +457,11 @@ Generated pages: 451. - [gog sheets rename-tab](gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet - [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns - [gog sheets resize-rows](gog-sheets-resize-rows.md) - Resize sheet rows + - [gog sheets table](gog-sheets-table.md) - Manage Google Sheets tables + - [gog sheets table create](gog-sheets-table-create.md) - Create a table + - [gog sheets table delete](gog-sheets-table-delete.md) - Delete a table + - [gog sheets table get](gog-sheets-table-get.md) - Get a table + - [gog sheets table list](gog-sheets-table-list.md) - List tables in a spreadsheet - [gog sheets unmerge](gog-sheets-unmerge.md) - Unmerge cells in a range - [gog sheets update](gog-sheets-update.md) - Update values in a range - [gog sheets update-note](gog-sheets-update-note.md) - Set or clear a cell note diff --git a/docs/commands/gog-sheets-table-create.md b/docs/commands/gog-sheets-table-create.md new file mode 100644 index 00000000..3a183d31 --- /dev/null +++ b/docs/commands/gog-sheets-table-create.md @@ -0,0 +1,44 @@ +# `gog sheets table create` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Create a table + +## Usage + +```bash +gog sheets (sheet) table (tables) create (add,new) --name=STRING --columns-json=STRING +``` + +## Parent + +- [gog sheets table](gog-sheets-table.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--columns-json` | `string` | | Column definitions as JSON array or @file (columnName + optional columnType; valid types include TEXT, DOUBLE, BOOLEAN, DATE, DROPDOWN) | +| `--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. | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--name` | `string` | | Table name | +| `--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) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets table](gog-sheets-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-table-delete.md b/docs/commands/gog-sheets-table-delete.md new file mode 100644 index 00000000..13c05947 --- /dev/null +++ b/docs/commands/gog-sheets-table-delete.md @@ -0,0 +1,42 @@ +# `gog sheets table delete` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Delete a table + +## Usage + +```bash +gog sheets (sheet) table (tables) delete (rm,remove,del) +``` + +## Parent + +- [gog sheets table](gog-sheets-table.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--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. | +| `-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) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets table](gog-sheets-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-table-get.md b/docs/commands/gog-sheets-table-get.md new file mode 100644 index 00000000..7501c9bf --- /dev/null +++ b/docs/commands/gog-sheets-table-get.md @@ -0,0 +1,42 @@ +# `gog sheets table get` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Get a table + +## Usage + +```bash +gog sheets (sheet) table (tables) get (show,info) +``` + +## Parent + +- [gog sheets table](gog-sheets-table.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--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. | +| `-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) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets table](gog-sheets-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-table-list.md b/docs/commands/gog-sheets-table-list.md new file mode 100644 index 00000000..3087259f --- /dev/null +++ b/docs/commands/gog-sheets-table-list.md @@ -0,0 +1,42 @@ +# `gog sheets table list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List tables in a spreadsheet + +## Usage + +```bash +gog sheets (sheet) table (tables) list +``` + +## Parent + +- [gog sheets table](gog-sheets-table.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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--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. | +| `-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) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets table](gog-sheets-table.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-table.md b/docs/commands/gog-sheets-table.md new file mode 100644 index 00000000..b752a50e --- /dev/null +++ b/docs/commands/gog-sheets-table.md @@ -0,0 +1,49 @@ +# `gog sheets table` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Manage Google Sheets tables + +## Usage + +```bash +gog sheets (sheet) table (tables) +``` + +## Parent + +- [gog sheets](gog-sheets.md) + +## Subcommands + +- [gog sheets table create](gog-sheets-table-create.md) - Create a table +- [gog sheets table delete](gog-sheets-table-delete.md) - Delete a table +- [gog sheets table get](gog-sheets-table-get.md) - Get a table +- [gog sheets table list](gog-sheets-table-list.md) - List tables in a spreadsheet + +## 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/docs/slides/contacts/tasks/people/sheets/forms/appscript/ads) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--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. | +| `-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) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets](gog-sheets.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets.md b/docs/commands/gog-sheets.md index 6155d39e..bbf943fa 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -40,6 +40,7 @@ gog sheets (sheet) [flags] - [gog sheets rename-tab](gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet - [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns - [gog sheets resize-rows](gog-sheets-resize-rows.md) - Resize sheet rows +- [gog sheets table](gog-sheets-table.md) - Manage Google Sheets tables - [gog sheets unmerge](gog-sheets-unmerge.md) - Unmerge cells in a range - [gog sheets update](gog-sheets-update.md) - Update values in a range - [gog sheets update-note](gog-sheets-update-note.md) - Set or clear a cell note diff --git a/docs/sheets-tables.md b/docs/sheets-tables.md new file mode 100644 index 00000000..e2066dd9 --- /dev/null +++ b/docs/sheets-tables.md @@ -0,0 +1,108 @@ +# Sheets Tables + +Use `gog sheets table` to manage Google Sheets structured tables. Tables are +different from plain cell ranges: Sheets tracks a table ID, table name, typed +columns, and a bounded table range. + +## When To Use + +- Use tables when a spreadsheet has structured rows with stable column names. +- Use normal `gog sheets get`, `update`, `append`, and `clear` for plain ranges. +- Use named ranges when you only need a reusable range selector. + +## Create A Table + +Create requires a spreadsheet ID, a sheet-qualified range, a table name, and +column definitions: + +```bash +gog sheets table create "$spreadsheet_id" 'Sheet1!A1:C4' \ + --name Tasks \ + --columns-json '[{"columnName":"Task","columnType":"TEXT"},{"columnName":"Amount","columnType":"DOUBLE"},{"columnName":"Done","columnType":"BOOLEAN"}]' +``` + +`--columns-json` accepts inline JSON or `@file`. If `columnType` is omitted, it +defaults to `TEXT`. + +```json +[ + {"columnName": "Task"}, + {"columnName": "Amount", "columnType": "DOUBLE"}, + {"columnName": "Done", "columnType": "BOOLEAN"} +] +``` + +The range can be A1 notation with a sheet name, or an existing named range: + +```bash +gog sheets table create "$spreadsheet_id" MyNamedRange \ + --name Tasks \ + --columns-json @columns.json +``` + +## Column Types + +`gog` validates table column types before sending the mutation to Google. Use +the Sheets API enum names: + +| Use | Instead of | +| --- | --- | +| `DOUBLE` | `NUMBER` | +| `BOOLEAN` | `CHECKBOX` | +| `RATINGS_CHIP` | `RATING` | +| `FILES_CHIP`, `PEOPLE_CHIP`, `FINANCE_CHIP`, or `PLACE_CHIP` | `SMART_CHIP` | + +Supported create types are `TEXT`, `DOUBLE`, `CURRENCY`, `PERCENT`, `DATE`, +`TIME`, `DATE_TIME`, `BOOLEAN`, `DROPDOWN`, `FILES_CHIP`, `PEOPLE_CHIP`, +`FINANCE_CHIP`, `PLACE_CHIP`, and `RATINGS_CHIP`. + +Dropdown validation can be supplied with `dataValidationRule`, and only with +`columnType: "DROPDOWN"`. + +## Inspect Tables + +List tables: + +```bash +gog sheets table list "$spreadsheet_id" +gog sheets table list "$spreadsheet_id" --json +``` + +Read one table by ID or name: + +```bash +gog sheets table get "$spreadsheet_id" "$table_id" +gog sheets table get "$spreadsheet_id" Tasks --json +``` + +JSON output includes the table ID, table name, sheet title, A1 range, raw +`GridRange`, and typed columns. + +## Delete A Table + +Deleting removes the table object. Use `--force` for non-interactive runs: + +```bash +gog sheets table delete "$spreadsheet_id" "$table_id" --force +``` + +Use `--dry-run` to preview the delete request without mutating the spreadsheet: + +```bash +gog sheets table delete "$spreadsheet_id" "$table_id" --dry-run --json +``` + +## Current Scope + +This first table command set intentionally covers list, get, create, and delete +only. Row append, table update, footer handling, and table-aware clear behavior +need separate semantics because the plain Sheets range APIs can touch table +headers or footer rows if used blindly. + +## Command Pages + +- [`gog sheets table`](commands/gog-sheets-table.md) +- [`gog sheets table list`](commands/gog-sheets-table-list.md) +- [`gog sheets table get`](commands/gog-sheets-table-get.md) +- [`gog sheets table create`](commands/gog-sheets-table-create.md) +- [`gog sheets table delete`](commands/gog-sheets-table-delete.md) diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index bb7c85da..5170a999 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -43,6 +43,7 @@ type SheetsCmd struct { FindReplace SheetsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text across a spreadsheet"` Links SheetsLinksCmd `cmd:"" name:"links" aliases:"hyperlinks" help:"Get cell hyperlinks from a range"` Named SheetsNamedRangesCmd `cmd:"" name:"named-ranges" aliases:"namedranges,nr" help:"Manage named ranges"` + Table SheetsTableCmd `cmd:"" name:"table" aliases:"tables" help:"Manage Google Sheets tables"` Metadata SheetsMetadataCmd `cmd:"" name:"metadata" aliases:"info" help:"Get spreadsheet metadata"` Raw SheetsRawCmd `cmd:"" name:"raw" help:"Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption)"` Create SheetsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a new spreadsheet"` diff --git a/internal/cmd/sheets_table.go b/internal/cmd/sheets_table.go new file mode 100644 index 00000000..8e479f91 --- /dev/null +++ b/internal/cmd/sheets_table.go @@ -0,0 +1,408 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsTableCmd struct { + List SheetsTableListCmd `cmd:"" default:"withargs" help:"List tables in a spreadsheet"` + Get SheetsTableGetCmd `cmd:"" name:"get" aliases:"show,info" help:"Get a table"` + Create SheetsTableCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a table"` + Delete SheetsTableDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove,del" help:"Delete a table"` +} + +type SheetsTableListCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` +} + +func (c *SheetsTableListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + tables, err := fetchSpreadsheetTables(ctx, svc, spreadsheetID) + if err != nil { + return err + } + sort.Slice(tables, func(i, j int) bool { + if tables[i].Name == tables[j].Name { + return tables[i].TableID < tables[j].TableID + } + return tables[i].Name < tables[j].Name + }) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"tables": tables}) + } + + if len(tables) == 0 { + u.Err().Println("No tables") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "NAME\tTABLE_ID\tSHEET_ID\tSHEET_TITLE\tA1\tCOLUMNS") + for _, table := range tables { + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%d\n", + table.Name, + table.TableID, + table.SheetID, + table.SheetTitle, + table.A1, + len(table.Columns), + ) + } + return nil +} + +type SheetsTableGetCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + TableID string `arg:"" name:"tableId" help:"Table ID or table name"` +} + +func (c *SheetsTableGetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + in := strings.TrimSpace(c.TableID) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if in == "" { + return usage("empty tableId") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + tables, err := fetchSpreadsheetTables(ctx, svc, spreadsheetID) + if err != nil { + return err + } + table, found, err := resolveSheetsTable(in, tables) + if err != nil { + return err + } + if !found { + return usagef("unknown table %q", in) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"table": table}) + } + + u.Out().Printf("name\t%s", table.Name) + u.Out().Printf("id\t%s", table.TableID) + u.Out().Printf("sheet\t%s", table.SheetTitle) + u.Out().Printf("a1\t%s", table.A1) + for _, col := range table.Columns { + u.Out().Printf("column\t%d\t%s\t%s", col.ColumnIndex, col.ColumnName, col.ColumnType) + } + return nil +} + +type SheetsTableCreateCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Range string `arg:"" name:"range" help:"Table range (A1 notation with sheet name, or named range name; e.g. Sheet1!A1:C10 or MyNamedRange)"` + Name string `name:"name" help:"Table name" required:""` + ColumnsJSON string `name:"columns-json" help:"Column definitions as JSON array or @file (columnName + optional columnType; valid types include TEXT, DOUBLE, BOOLEAN, DATE, DROPDOWN)" required:""` +} + +func (c *SheetsTableCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + rangeSpec := cleanRange(strings.TrimSpace(c.Range)) + name := strings.TrimSpace(c.Name) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if rangeSpec == "" { + return usage("empty range") + } + if name == "" { + return usage("empty name") + } + + columns, err := parseSheetsTableColumnsJSON(c.ColumnsJSON) + if err != nil { + return err + } + + if dryRunErr := dryRunExit(ctx, flags, "sheets.table.create", map[string]any{ + "spreadsheet_id": spreadsheetID, + "range": rangeSpec, + "name": name, + "columns": columns, + }); dryRunErr != nil { + return dryRunErr + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + catalog, err := fetchSpreadsheetRangeCatalog(ctx, svc, spreadsheetID) + if err != nil { + return err + } + gridRange, err := resolveGridRangeWithCatalog(rangeSpec, catalog, "table") + if err != nil { + return err + } + + table := &sheets.Table{ + Name: name, + Range: gridRange, + ColumnProperties: columns, + } + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + AddTable: &sheets.AddTableRequest{Table: table}, + }, + }, + } + + resp, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do() + if err != nil { + return err + } + + created := table + if resp != nil && len(resp.Replies) == 1 && resp.Replies[0] != nil && resp.Replies[0].AddTable != nil && resp.Replies[0].AddTable.Table != nil { + created = resp.Replies[0].AddTable.Table + } + item := sheetsTableToItem(created, catalog) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"table": item}) + } + + u.Out().Printf("created\t%s", item.TableID) + u.Out().Printf("name\t%s", item.Name) + u.Out().Printf("a1\t%s", item.A1) + return nil +} + +type SheetsTableDeleteCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + TableID string `arg:"" name:"tableId" help:"Table ID or table name"` +} + +func (c *SheetsTableDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + in := strings.TrimSpace(c.TableID) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if in == "" { + return usage("empty tableId") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + tables, err := fetchSpreadsheetTables(ctx, svc, spreadsheetID) + if err != nil { + return err + } + table, found, err := resolveSheetsTable(in, tables) + if err != nil { + return err + } + if !found { + return usagef("unknown table %q", in) + } + + if dryRunErr := dryRunExit(ctx, flags, "sheets.table.delete", map[string]any{ + "spreadsheet_id": spreadsheetID, + "table_id": table.TableID, + "name": table.Name, + }); dryRunErr != nil { + return dryRunErr + } + if err := confirmDestructiveChecked(ctx, flagsWithoutDryRun(flags), "delete table "+table.Name); err != nil { + return err + } + + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + DeleteTable: &sheets.DeleteTableRequest{TableId: table.TableID}, + }, + }, + } + if _, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Do(); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "deleted": map[string]any{ + "tableId": table.TableID, + "name": table.Name, + }, + }) + } + + u.Out().Printf("deleted\t%s", table.TableID) + return nil +} + +type sheetsTableItem struct { + Name string `json:"name"` + TableID string `json:"tableId"` + SheetID int64 `json:"sheetId"` + SheetTitle string `json:"sheetTitle"` + A1 string `json:"a1"` + Range *sheets.GridRange `json:"range,omitempty"` + Columns []sheetsTableColumnItem `json:"columns,omitempty"` +} + +type sheetsTableColumnItem struct { + ColumnIndex int64 `json:"columnIndex"` + ColumnName string `json:"columnName"` + ColumnType string `json:"columnType"` +} + +func fetchSpreadsheetTables(ctx context.Context, svc *sheets.Service, spreadsheetID string) ([]sheetsTableItem, error) { + call := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(properties(sheetId,title),tables(tableId,name,range,columnProperties(columnIndex,columnName,columnType,dataValidationRule)))") + if ctx != nil { + call = call.Context(ctx) + } + resp, err := call.Do() + if err != nil { + return nil, fmt.Errorf("get spreadsheet tables: %w", err) + } + + catalog := &spreadsheetRangeCatalog{ + SheetIDsByTitle: make(map[string]int64, len(resp.Sheets)), + SheetTitlesByID: make(map[int64]string, len(resp.Sheets)), + } + tables := make([]sheetsTableItem, 0) + for _, sh := range resp.Sheets { + if sh == nil { + continue + } + if sh.Properties != nil { + catalog.SheetIDsByTitle[sh.Properties.Title] = sh.Properties.SheetId + catalog.SheetTitlesByID[sh.Properties.SheetId] = sh.Properties.Title + } + for _, table := range sh.Tables { + if table == nil { + continue + } + tables = append(tables, sheetsTableToItem(table, catalog)) + } + } + return tables, nil +} + +func sheetsTableToItem(table *sheets.Table, catalog *spreadsheetRangeCatalog) sheetsTableItem { + if table == nil { + return sheetsTableItem{} + } + item := sheetsTableItem{ + Name: strings.TrimSpace(table.Name), + TableID: strings.TrimSpace(table.TableId), + Range: table.Range, + } + if table.Range != nil { + item.SheetID = table.Range.SheetId + if catalog != nil { + item.SheetTitle = catalog.SheetTitlesByID[table.Range.SheetId] + } + if item.SheetTitle != "" { + item.A1 = gridRangeToA1(item.SheetTitle, table.Range) + } + } + for _, col := range table.ColumnProperties { + if col == nil { + continue + } + item.Columns = append(item.Columns, sheetsTableColumnItem{ + ColumnIndex: col.ColumnIndex, + ColumnName: strings.TrimSpace(col.ColumnName), + ColumnType: strings.TrimSpace(col.ColumnType), + }) + } + sort.Slice(item.Columns, func(i, j int) bool { + return item.Columns[i].ColumnIndex < item.Columns[j].ColumnIndex + }) + return item +} + +func resolveSheetsTable(input string, tables []sheetsTableItem) (sheetsTableItem, bool, error) { + in := strings.TrimSpace(input) + if in == "" { + return sheetsTableItem{}, false, nil + } + + for _, table := range tables { + if table.TableID == in { + return table, true, nil + } + } + + var matches []sheetsTableItem + for _, table := range tables { + if strings.EqualFold(table.Name, in) { + matches = append(matches, table) + } + } + switch len(matches) { + case 0: + return sheetsTableItem{}, false, nil + case 1: + return matches[0], true, nil + default: + parts := make([]string, 0, len(matches)) + for _, match := range matches { + parts = append(parts, fmt.Sprintf("%s (%s)", match.Name, match.TableID)) + } + return sheetsTableItem{}, false, usagef("ambiguous table %q; matches: %s", in, strings.Join(parts, ", ")) + } +} diff --git a/internal/cmd/sheets_table_columns.go b/internal/cmd/sheets_table_columns.go new file mode 100644 index 00000000..e8185449 --- /dev/null +++ b/internal/cmd/sheets_table_columns.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "google.golang.org/api/sheets/v4" +) + +type sheetsTableColumnInput struct { + ColumnName string `json:"columnName"` + ColumnType string `json:"columnType"` + DataValidation *sheets.TableColumnDataValidationRule `json:"dataValidation,omitempty"` + DataValidationRule *sheets.TableColumnDataValidationRule `json:"dataValidationRule,omitempty"` +} + +func parseSheetsTableColumnsJSON(input string) ([]*sheets.TableColumnProperties, error) { + b, err := resolveInlineOrFileBytes(input) + if err != nil { + return nil, fmt.Errorf("read --columns-json: %w", err) + } + + var raw []sheetsTableColumnInput + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("invalid columns JSON: %w", err) + } + var extra any + if err := dec.Decode(&extra); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("invalid columns JSON: multiple JSON values") + } + return nil, fmt.Errorf("invalid columns JSON: %w", err) + } + if len(raw) == 0 { + return nil, usage("provide at least one table column") + } + + cols := make([]*sheets.TableColumnProperties, 0, len(raw)) + for i, in := range raw { + name := strings.TrimSpace(in.ColumnName) + if name == "" { + return nil, usagef("columns[%d].columnName is required", i) + } + colType, err := normalizeSheetsTableColumnType(in.ColumnType) + if err != nil { + return nil, usagef("columns[%d].columnType: %v", i, err) + } + validation := in.DataValidationRule + if validation == nil { + validation = in.DataValidation + } + if validation != nil && colType != "DROPDOWN" { + return nil, usagef("columns[%d].dataValidationRule requires columnType DROPDOWN", i) + } + col := &sheets.TableColumnProperties{ + ColumnIndex: int64(i), + ColumnName: name, + ColumnType: colType, + DataValidationRule: validation, + } + col.ForceSendFields = []string{"ColumnIndex"} + cols = append(cols, col) + } + return cols, nil +} + +func normalizeSheetsTableColumnType(input string) (string, error) { + t := strings.ToUpper(strings.TrimSpace(input)) + if t == "" { + return "TEXT", nil + } + switch t { + case "NUMBER": + return "", fmt.Errorf("NUMBER is not a Sheets table column type; use DOUBLE") + case "CHECKBOX": + return "", fmt.Errorf("CHECKBOX is not a Sheets table column type; use BOOLEAN") + case "RATING": + return "", fmt.Errorf("RATING is not a Sheets table column type; use RATINGS_CHIP") + case "SMART_CHIP": + return "", fmt.Errorf("SMART_CHIP is not a Sheets table column type; use FILES_CHIP, PEOPLE_CHIP, FINANCE_CHIP, or PLACE_CHIP") + case "COLUMN_TYPE_UNSPECIFIED": + return "", fmt.Errorf("COLUMN_TYPE_UNSPECIFIED is not valid for create") + } + if validSheetsTableColumnTypes[t] { + return t, nil + } + return "", fmt.Errorf("unknown type %q; valid types: %s", t, strings.Join(validSheetsTableColumnTypeNames(), ", ")) +} + +var validSheetsTableColumnTypes = map[string]bool{ + "TEXT": true, + "DOUBLE": true, + "CURRENCY": true, + "PERCENT": true, + "DATE": true, + "TIME": true, + "DATE_TIME": true, + "BOOLEAN": true, + "DROPDOWN": true, + "FILES_CHIP": true, + "PEOPLE_CHIP": true, + "FINANCE_CHIP": true, + "PLACE_CHIP": true, + "RATINGS_CHIP": true, +} + +func validSheetsTableColumnTypeNames() []string { + names := make([]string, 0, len(validSheetsTableColumnTypes)) + for name := range validSheetsTableColumnTypes { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/cmd/sheets_table_test.go b/internal/cmd/sheets_table_test.go new file mode 100644 index 00000000..dbdf131f --- /dev/null +++ b/internal/cmd/sheets_table_test.go @@ -0,0 +1,232 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" +) + +func TestSheetsTableCreateCmd(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var gotReq sheets.BatchUpdateSpreadsheetRequest + var gotBody string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + switch { + case strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "sheets": []map[string]any{ + {"properties": map[string]any{"sheetId": 42, "title": "Sheet1"}}, + }, + }) + case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read batchUpdate: %v", err) + } + gotBody = string(body) + if err := json.Unmarshal(body, &gotReq); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "replies": []map[string]any{ + { + "addTable": map[string]any{ + "table": map[string]any{ + "tableId": "tbl1", + "name": "Tasks", + "range": map[string]any{ + "sheetId": 42, + "startRowIndex": 0, + "endRowIndex": 4, + "startColumnIndex": 0, + "endColumnIndex": 3, + }, + "columnProperties": []map[string]any{ + {"columnIndex": 0, "columnName": "Task", "columnType": "TEXT"}, + {"columnIndex": 1, "columnName": "Amount", "columnType": "DOUBLE"}, + {"columnIndex": 2, "columnName": "Done", "columnType": "BOOLEAN"}, + }, + }, + }, + }, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + installSheetsTestService(t, srv) + + ctx := newCmdJSONContext(t) + cmd := &SheetsTableCreateCmd{} + out := captureStdout(t, func() { + if err := runKong(t, cmd, []string{ + "s1", + "Sheet1!A1:C4", + "--name", "Tasks", + "--columns-json", `[{"columnName":"Task"},{"columnName":"Amount","columnType":"DOUBLE"},{"columnName":"Done","columnType":"BOOLEAN"}]`, + }, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("create table: %v", err) + } + }) + + if len(gotReq.Requests) != 1 || gotReq.Requests[0].AddTable == nil || gotReq.Requests[0].AddTable.Table == nil { + t.Fatalf("expected addTable request, got %#v", gotReq.Requests) + } + table := gotReq.Requests[0].AddTable.Table + if table.Name != "Tasks" { + t.Fatalf("table name = %q", table.Name) + } + if table.Range == nil || table.Range.SheetId != 42 || table.Range.EndRowIndex != 4 || table.Range.EndColumnIndex != 3 { + t.Fatalf("range = %#v", table.Range) + } + if len(table.ColumnProperties) != 3 { + t.Fatalf("columns = %#v", table.ColumnProperties) + } + if table.ColumnProperties[0].ColumnType != "TEXT" { + t.Fatalf("default column type = %q", table.ColumnProperties[0].ColumnType) + } + if table.ColumnProperties[1].ColumnType != "DOUBLE" || table.ColumnProperties[2].ColumnType != "BOOLEAN" { + t.Fatalf("column types = %#v", table.ColumnProperties) + } + if !strings.Contains(gotBody, `"columnIndex":0`) { + t.Fatalf("expected zero columnIndex to be sent, body: %s", gotBody) + } + if !strings.Contains(out, `"tableId": "tbl1"`) { + t.Fatalf("missing JSON table id: %s", out) + } +} + +func TestSheetsTableColumnTypeAliasesFailFast(t *testing.T) { + tests := map[string]string{ + "NUMBER": "use DOUBLE", + "CHECKBOX": "use BOOLEAN", + "RATING": "use RATINGS_CHIP", + "SMART_CHIP": "use FILES_CHIP", + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + _, err := parseSheetsTableColumnsJSON(`[{"columnName":"Value","columnType":"` + input + `"}]`) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q, want %q", err.Error(), want) + } + }) + } +} + +func TestSheetsTableListGetDelete(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var deletedID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + switch { + case strings.HasPrefix(path, "/spreadsheets/s1") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "sheets": []map[string]any{ + { + "properties": map[string]any{"sheetId": 42, "title": "Sheet1"}, + "tables": []map[string]any{ + { + "tableId": "tbl1", + "name": "Tasks", + "range": map[string]any{ + "sheetId": 42, + "startRowIndex": 0, + "endRowIndex": 4, + "startColumnIndex": 0, + "endColumnIndex": 3, + }, + "columnProperties": []map[string]any{ + {"columnIndex": 0, "columnName": "Task", "columnType": "TEXT"}, + }, + }, + }, + }, + }, + }) + case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost: + var req sheets.BatchUpdateSpreadsheetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + if len(req.Requests) != 1 || req.Requests[0].DeleteTable == nil { + t.Fatalf("expected deleteTable request, got %#v", req.Requests) + } + deletedID = req.Requests[0].DeleteTable.TableId + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + installSheetsTestService(t, srv) + + listOut := captureStdout(t, func() { + if err := (&SheetsTableListCmd{SpreadsheetID: "s1"}).Run(newCmdJSONContext(t), &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("list tables: %v", err) + } + }) + if !strings.Contains(listOut, `"a1": "Sheet1!A1:C4"`) { + t.Fatalf("missing A1 output: %s", listOut) + } + + getOut := captureStdout(t, func() { + if err := (&SheetsTableGetCmd{SpreadsheetID: "s1", TableID: "Tasks"}).Run(newCmdJSONContext(t), &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("get table: %v", err) + } + }) + if !strings.Contains(getOut, `"tableId": "tbl1"`) { + t.Fatalf("missing table output: %s", getOut) + } + + deleteOut := captureStdout(t, func() { + if err := (&SheetsTableDeleteCmd{SpreadsheetID: "s1", TableID: "tbl1"}).Run(newCmdJSONContext(t), &RootFlags{Account: "a@b.com", Force: true}); err != nil { + t.Fatalf("delete table: %v", err) + } + }) + if deletedID != "tbl1" { + t.Fatalf("deleted table id = %q", deletedID) + } + if !strings.Contains(deleteOut, `"tableId": "tbl1"`) { + t.Fatalf("missing delete output: %s", deleteOut) + } +} + +func installSheetsTestService(t *testing.T, srv *httptest.Server) { + t.Helper() + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } +} diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index b4abdc9f..f2e15da2 100755 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -11,7 +11,7 @@ const sections = [ ["Start", ["README.md", "auth-clients.md", "spec.md", "dates.md"]], ["Commands", rels("commands")], ["Gmail", ["gmail-autoreply.md", "watch.md", "email-tracking.md", "email-tracking-worker.md"]], - ["Workspace", ["backup.md", "contacts-json-update.md", "slides-markdown.md", "slides-template-replacement.md", "sedmat.md"]], + ["Workspace", ["backup.md", "sheets-tables.md", "contacts-json-update.md", "slides-markdown.md", "slides-template-replacement.md", "sedmat.md"]], ["Safety", ["safety-profiles.md", "RELEASING.md"]], ];