feat(sheets): append table rows

Adds table-aware row appends for Google Sheets tables, including docs, generated command page, width validation, and live Google smoke verification.
This commit is contained in:
Peter Steinberger 2026-05-04 10:09:36 +01:00 committed by GitHub
parent 322695f78c
commit 5d3b1c0980
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 347 additions and 7 deletions

View File

@ -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.

View File

@ -1391,6 +1391,7 @@ gog sheets named-ranges delete <spreadsheetId> MyNamedRange2
# Tables
gog sheets table list <spreadsheetId>
gog sheets table create <spreadsheetId> 'Sheet1!A1:C4' --name Tasks --columns-json '[{"columnName":"Task","columnType":"TEXT"},{"columnName":"Amount","columnType":"DOUBLE"},{"columnName":"Done","columnType":"BOOLEAN"}]'
gog sheets table append <spreadsheetId> <tableId> --values-json '[["Write docs",2,true]]'
gog sheets table get <spreadsheetId> <tableId>
gog sheets table delete <spreadsheetId> <tableId> --force
# See docs/sheets-tables.md for valid column types and current command scope.

View File

@ -415,6 +415,7 @@ Generated from `gog schema --json`.
- [`gog sheets (sheet) resize-columns <spreadsheetId> <columns> [flags]`](commands/gog-sheets-resize-columns.md) - Resize sheet columns
- [`gog sheets (sheet) resize-rows <spreadsheetId> <rows> [flags]`](commands/gog-sheets-resize-rows.md) - Resize sheet rows
- [`gog sheets (sheet) table (tables) <command>`](commands/gog-sheets-table.md) - Manage Google Sheets tables
- [`gog sheets (sheet) table (tables) append (add-row,add-rows) <spreadsheetId> <tableId> [<values> ...] [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 <spreadsheetId> <range>`](commands/gog-sheets-table-create.md) - Create a table
- [`gog sheets (sheet) table (tables) delete (rm,remove,del) <spreadsheetId> <tableId>`](commands/gog-sheets-table-delete.md) - Delete a table
- [`gog sheets (sheet) table (tables) get (show,info) <spreadsheetId> <tableId>`](commands/gog-sheets-table-get.md) - Get a table

View File

@ -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

View File

@ -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) <spreadsheetId> <tableId> [<values> ...] [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`<br>`--account`<br>`--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`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--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`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--input` | `string` | USER_ENTERED | Value input option: RAW or USER_ENTERED |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `-p`<br>`--plain`<br>`--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`<br>`--pick`<br>`--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`<br>`--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)

View File

@ -16,6 +16,7 @@ gog sheets (sheet) table (tables) <command>
## 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

View File

@ -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)

View File

@ -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)

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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()