From 3150cfbc5ed0cab97e865cd6b90713b27e50e701 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 10:02:07 +0100 Subject: [PATCH] feat(sheets): append table rows --- CHANGELOG.md | 1 + README.md | 1 + docs/commands.generated.md | 1 + docs/commands/README.md | 3 +- docs/commands/gog-sheets-table-append.md | 44 +++++++ docs/commands/gog-sheets-table.md | 1 + docs/sheets-tables.md | 32 ++++- internal/cmd/sheets.go | 6 +- internal/cmd/sheets_table.go | 1 + internal/cmd/sheets_table_append.go | 155 +++++++++++++++++++++++ internal/cmd/sheets_table_test.go | 109 ++++++++++++++++ 11 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 docs/commands/gog-sheets-table-append.md create mode 100644 internal/cmd/sheets_table_append.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a48ba10..f0bc42d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Calendar: add `calendar move` / `calendar transfer` to move an event to another calendar and change its organizer. (#448) — thanks @markusbkoch. - Docs: add `docs add-tab`, `docs rename-tab`, and `docs delete-tab` for managing Google Docs tabs. (#547) — thanks @chopenhauer. - Docs: support tab-scoped Markdown append and find-replace flows. (#541) — thanks @donbowman. +- Sheets: add `sheets table append` for appending rows to structured Sheets tables without targeting headers directly. ### Fixed - Agent safety: compile baked safety profile policies into generated hash switches so raw allow/deny rule strings are not embedded as patchable YAML. (#540) — thanks @drewburchfield. diff --git a/README.md b/README.md index da7b498b..fd4e7cdc 100644 --- a/README.md +++ b/README.md @@ -1391,6 +1391,7 @@ 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 append --values-json '[["Write docs",2,true]]' gog sheets table get gog sheets table delete --force # See docs/sheets-tables.md for valid column types and current command scope. diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 879e7b69..96425c86 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -415,6 +415,7 @@ Generated from `gog schema --json`. - [`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) append (add-row,add-rows) [ ...] [flags]`](commands/gog-sheets-table-append.md) - Append rows to a table - [`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 diff --git a/docs/commands/README.md b/docs/commands/README.md index 63fefd53..c4046c41 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: 456. +Generated pages: 457. ## Top-level Commands @@ -458,6 +458,7 @@ Generated pages: 456. - [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 append](gog-sheets-table-append.md) - Append rows to a table - [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 diff --git a/docs/commands/gog-sheets-table-append.md b/docs/commands/gog-sheets-table-append.md new file mode 100644 index 00000000..7c5a6d24 --- /dev/null +++ b/docs/commands/gog-sheets-table-append.md @@ -0,0 +1,44 @@ +# `gog sheets table append` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Append rows to a table + +## Usage + +```bash +gog sheets (sheet) table (tables) append (add-row,add-rows) [ ...] [flags] +``` + +## 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. | +| `--input` | `string` | USER_ENTERED | Value input option: RAW or USER_ENTERED | +| `-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. | +| `--values-json` | `string` | | Values as JSON 2D array | +| `-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 index b752a50e..ccf09dff 100644 --- a/docs/commands/gog-sheets-table.md +++ b/docs/commands/gog-sheets-table.md @@ -16,6 +16,7 @@ gog sheets (sheet) table (tables) ## Subcommands +- [gog sheets table append](gog-sheets-table-append.md) - Append rows to a table - [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 diff --git a/docs/sheets-tables.md b/docs/sheets-tables.md index e2066dd9..e8925495 100644 --- a/docs/sheets-tables.md +++ b/docs/sheets-tables.md @@ -78,6 +78,29 @@ 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. +## Append Rows + +Append rows by table ID or name: + +```bash +gog sheets table append "$spreadsheet_id" "$table_id" \ + --values-json '[["Write docs",2,true]]' +``` + +Positional values use the same comma-separated row, pipe-separated cell syntax +as `gog sheets append`: + +```bash +gog sheets table append "$spreadsheet_id" Tasks 'Write docs|2|true' +gog sheets table append "$spreadsheet_id" Tasks 'One|1|false,Two|2|true' +``` + +`sheets table append` resolves the table first, then calls the Sheets append API +against the table's bounded A1 range with `INSERT_ROWS`. This lets Sheets place +new rows after the current table data and expand the table, without targeting +the header row directly. Rows wider than the table's column count are rejected +before the mutation is sent. + ## Delete A Table Deleting removes the table object. Use `--force` for non-interactive runs: @@ -94,10 +117,10 @@ 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. +This table command set intentionally covers list, get, create, append, and +delete. Table update, footer editing, 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 @@ -105,4 +128,5 @@ headers or footer rows if used blindly. - [`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 append`](commands/gog-sheets-table-append.md) - [`gog sheets table delete`](commands/gog-sheets-table-delete.md) diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 5170a999..a47cedaa 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -18,6 +18,8 @@ import ( var newSheetsService = googleapi.NewSheets +const sheetsDefaultValueInputOption = "USER_ENTERED" + // cleanRange removes shell escape sequences from range arguments. // Some shells escape ! to \! (bash history expansion), which breaks Google Sheets API calls. func cleanRange(r string) string { @@ -201,7 +203,7 @@ func (c *SheetsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { valueInputOption := strings.TrimSpace(c.ValueInput) if valueInputOption == "" { - valueInputOption = "USER_ENTERED" + valueInputOption = sheetsDefaultValueInputOption } if err := dryRunExit(ctx, flags, "sheets.update", map[string]any{ @@ -309,7 +311,7 @@ func (c *SheetsAppendCmd) Run(ctx context.Context, flags *RootFlags) error { valueInputOption := strings.TrimSpace(c.ValueInput) if valueInputOption == "" { - valueInputOption = "USER_ENTERED" + valueInputOption = sheetsDefaultValueInputOption } insertDataOption := strings.TrimSpace(c.Insert) diff --git a/internal/cmd/sheets_table.go b/internal/cmd/sheets_table.go index 8e479f91..f3518743 100644 --- a/internal/cmd/sheets_table.go +++ b/internal/cmd/sheets_table.go @@ -17,6 +17,7 @@ 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"` + Append SheetsTableAppendCmd `cmd:"" name:"append" aliases:"add-row,add-rows" help:"Append rows to a table"` Delete SheetsTableDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove,del" help:"Delete a table"` } diff --git a/internal/cmd/sheets_table_append.go b/internal/cmd/sheets_table_append.go new file mode 100644 index 00000000..fcb99b64 --- /dev/null +++ b/internal/cmd/sheets_table_append.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsTableAppendCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + TableID string `arg:"" name:"tableId" help:"Table ID or table name"` + Values []string `arg:"" optional:"" name:"values" help:"Values (comma-separated rows, pipe-separated cells)"` + ValueInput string `name:"input" help:"Value input option: RAW or USER_ENTERED" default:"USER_ENTERED"` + ValuesJSON string `name:"values-json" help:"Values as JSON 2D array"` +} + +func (c *SheetsTableAppendCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + in := strings.TrimSpace(c.TableID) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if in == "" { + return usage("empty tableId") + } + + values, err := parseSheetsAppendValues(c.ValuesJSON, c.Values) + if err != nil { + return err + } + valueInputOption := strings.TrimSpace(c.ValueInput) + if valueInputOption == "" { + valueInputOption = sheetsDefaultValueInputOption + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + 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 strings.TrimSpace(table.A1) == "" { + return fmt.Errorf("table %q has no bounded A1 range", table.TableID) + } + if widthErr := validateSheetsTableAppendWidth(table, values); widthErr != nil { + return widthErr + } + + if dryRunErr := dryRunExit(ctx, flags, "sheets.table.append", map[string]any{ + "spreadsheet_id": spreadsheetID, + "table_id": table.TableID, + "name": table.Name, + "range": table.A1, + "values": values, + "value_input_option": valueInputOption, + "insert_data_option": "INSERT_ROWS", + }); dryRunErr != nil { + return dryRunErr + } + + resp, err := svc.Spreadsheets.Values.Append(spreadsheetID, table.A1, &sheets.ValueRange{Values: values}). + ValueInputOption(valueInputOption). + InsertDataOption("INSERT_ROWS"). + Do() + if err != nil { + return err + } + if resp == nil || resp.Updates == nil { + return fmt.Errorf("append response missing update metadata") + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "tableId": table.TableID, + "name": table.Name, + "tableRange": table.A1, + "updatedRange": resp.Updates.UpdatedRange, + "updatedRows": resp.Updates.UpdatedRows, + "updatedColumns": resp.Updates.UpdatedColumns, + "updatedCells": resp.Updates.UpdatedCells, + }) + } + + u.Out().Printf("Appended %d cells to %s", resp.Updates.UpdatedCells, resp.Updates.UpdatedRange) + return nil +} + +func parseSheetsAppendValues(valuesJSON string, values []string) ([][]interface{}, error) { + switch { + case strings.TrimSpace(valuesJSON) != "": + b, err := resolveInlineOrFileBytes(valuesJSON) + if err != nil { + return nil, fmt.Errorf("read --values-json: %w", err) + } + var parsed [][]interface{} + if err := json.Unmarshal(b, &parsed); err != nil { + return nil, fmt.Errorf("invalid JSON values: %w", err) + } + if len(parsed) == 0 { + return nil, fmt.Errorf("provide at least one row") + } + return parsed, nil + case len(values) > 0: + rawValues := strings.Join(values, " ") + rows := strings.Split(rawValues, ",") + parsed := make([][]interface{}, 0, len(rows)) + for _, row := range rows { + cells := strings.Split(strings.TrimSpace(row), "|") + rowData := make([]interface{}, len(cells)) + for i, cell := range cells { + rowData[i] = strings.TrimSpace(cell) + } + parsed = append(parsed, rowData) + } + return parsed, nil + default: + return nil, fmt.Errorf("provide values as args or via --values-json") + } +} + +func validateSheetsTableAppendWidth(table sheetsTableItem, values [][]interface{}) error { + if len(table.Columns) == 0 { + return nil + } + width := len(table.Columns) + for i, row := range values { + if len(row) > width { + return usagef("row %d has %d cells, but table %q has %d columns", i+1, len(row), table.Name, width) + } + } + return nil +} diff --git a/internal/cmd/sheets_table_test.go b/internal/cmd/sheets_table_test.go index dbdf131f..73b037f9 100644 --- a/internal/cmd/sheets_table_test.go +++ b/internal/cmd/sheets_table_test.go @@ -217,6 +217,115 @@ func TestSheetsTableListGetDelete(t *testing.T) { } } +func TestSheetsTableAppendCmd(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var gotRange string + var gotInsert string + var gotInput string + var gotValues sheets.ValueRange + + 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"}, + {"columnIndex": 1, "columnName": "Amount", "columnType": "DOUBLE"}, + {"columnIndex": 2, "columnName": "Done", "columnType": "BOOLEAN"}, + }, + }, + }, + }, + }, + }) + case strings.Contains(path, "/spreadsheets/s1/values/") && strings.Contains(path, ":append") && r.Method == http.MethodPost: + gotRange = strings.TrimSuffix(strings.TrimPrefix(path, "/spreadsheets/s1/values/"), ":append") + gotInsert = r.URL.Query().Get("insertDataOption") + gotInput = r.URL.Query().Get("valueInputOption") + if err := json.NewDecoder(r.Body).Decode(&gotValues); err != nil { + t.Fatalf("decode append: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "updates": map[string]any{ + "updatedRange": "Sheet1!A4:C4", + "updatedRows": 1, + "updatedColumns": 3, + "updatedCells": 3, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + installSheetsTestService(t, srv) + + out := captureStdout(t, func() { + cmd := &SheetsTableAppendCmd{} + if err := runKong(t, cmd, []string{ + "s1", + "Tasks", + "--values-json", `[["Write docs",2,true]]`, + }, newCmdJSONContext(t), &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("append table: %v", err) + } + }) + + if gotRange != "Sheet1!A1:C4" { + t.Fatalf("append range = %q", gotRange) + } + if gotInsert != "INSERT_ROWS" { + t.Fatalf("insertDataOption = %q", gotInsert) + } + if gotInput != sheetsDefaultValueInputOption { + t.Fatalf("valueInputOption = %q", gotInput) + } + if len(gotValues.Values) != 1 || len(gotValues.Values[0]) != 3 { + t.Fatalf("values = %#v", gotValues.Values) + } + if !strings.Contains(out, `"tableId": "tbl1"`) || !strings.Contains(out, `"updatedRange": "Sheet1!A4:C4"`) { + t.Fatalf("missing append output: %s", out) + } +} + +func TestSheetsTableAppendRejectsTooWideRows(t *testing.T) { + table := sheetsTableItem{ + Name: "Tasks", + Columns: []sheetsTableColumnItem{ + {ColumnIndex: 0, ColumnName: "Task"}, + {ColumnIndex: 1, ColumnName: "Done"}, + }, + } + err := validateSheetsTableAppendWidth(table, [][]interface{}{{"a", "b", "c"}}) + if err == nil { + t.Fatal("expected width error") + } + if !strings.Contains(err.Error(), "has 3 cells") { + t.Fatalf("error = %q", err.Error()) + } +} + func installSheetsTestService(t *testing.T, srv *httptest.Server) { t.Helper()