diff --git a/CHANGELOG.md b/CHANGELOG.md index f0bc42d1..686846c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - 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. +- Sheets: add header-safe `sheets table clear` for clearing table data rows without touching headers or footers. ### 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 fd4e7cdc..5dc57b0e 100644 --- a/README.md +++ b/README.md @@ -1392,6 +1392,7 @@ gog sheets named-ranges delete MyNamedRange2 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 clear --force 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 96425c86..186c350d 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -416,6 +416,7 @@ Generated from `gog schema --json`. - [`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) clear (clear-rows) `](commands/gog-sheets-table-clear.md) - Clear table data rows - [`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 c4046c41..14c95943 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: 457. +Generated pages: 458. ## Top-level Commands @@ -459,6 +459,7 @@ Generated pages: 457. - [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 clear](gog-sheets-table-clear.md) - Clear table data rows - [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-clear.md b/docs/commands/gog-sheets-table-clear.md new file mode 100644 index 00000000..4bd24ad5 --- /dev/null +++ b/docs/commands/gog-sheets-table-clear.md @@ -0,0 +1,42 @@ +# `gog sheets table clear` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Clear table data rows + +## Usage + +```bash +gog sheets (sheet) table (tables) clear (clear-rows) +``` + +## 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 index ccf09dff..4357be43 100644 --- a/docs/commands/gog-sheets-table.md +++ b/docs/commands/gog-sheets-table.md @@ -17,6 +17,7 @@ gog sheets (sheet) table (tables) ## Subcommands - [gog sheets table append](gog-sheets-table-append.md) - Append rows to a table +- [gog sheets table clear](gog-sheets-table-clear.md) - Clear table data rows - [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 e8925495..37bedad0 100644 --- a/docs/sheets-tables.md +++ b/docs/sheets-tables.md @@ -101,6 +101,25 @@ 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. +## Clear Data Rows + +Clear table data by table ID or name: + +```bash +gog sheets table clear "$spreadsheet_id" "$table_id" --force +``` + +This clears only the table data body. It never includes the header row in the +clear range. If Sheets reports a footer row, `gog` skips the footer row too and +clears only the rows between header and footer. + +Header-only tables fail with a clear message instead of sending an empty or +header-touching mutation. Use `--dry-run --json` to preview the exact data range: + +```bash +gog sheets table clear "$spreadsheet_id" Tasks --dry-run --json +``` + ## Delete A Table Deleting removes the table object. Use `--force` for non-interactive runs: @@ -117,10 +136,10 @@ gog sheets table delete "$spreadsheet_id" "$table_id" --dry-run --json ## Current Scope -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. +This table command set intentionally covers list, get, create, append, clear +data rows, and delete. Table update and footer editing need separate semantics +because the plain Sheets range APIs can touch table headers or footer rows if +used blindly. ## Command Pages @@ -129,4 +148,5 @@ or footer rows if used blindly. - [`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 clear`](commands/gog-sheets-table-clear.md) - [`gog sheets table delete`](commands/gog-sheets-table-delete.md) diff --git a/internal/cmd/sheets_table.go b/internal/cmd/sheets_table.go index f3518743..9be0f5b4 100644 --- a/internal/cmd/sheets_table.go +++ b/internal/cmd/sheets_table.go @@ -18,6 +18,7 @@ type SheetsTableCmd struct { 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"` + Clear SheetsTableClearCmd `cmd:"" name:"clear" aliases:"clear-rows" help:"Clear table data rows"` Delete SheetsTableDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove,del" help:"Delete a table"` } @@ -298,7 +299,9 @@ type sheetsTableItem struct { SheetID int64 `json:"sheetId"` SheetTitle string `json:"sheetTitle"` A1 string `json:"a1"` + DataA1 string `json:"dataA1,omitempty"` Range *sheets.GridRange `json:"range,omitempty"` + HasFooter bool `json:"hasFooter,omitempty"` Columns []sheetsTableColumnItem `json:"columns,omitempty"` } @@ -310,7 +313,7 @@ type sheetsTableColumnItem struct { 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)))") + Fields("sheets(properties(sheetId,title),tables(tableId,name,range,rowsProperties(footerColorStyle),columnProperties(columnIndex,columnName,columnType,dataValidationRule)))") if ctx != nil { call = call.Context(ctx) } @@ -358,8 +361,12 @@ func sheetsTableToItem(table *sheets.Table, catalog *spreadsheetRangeCatalog) sh } if item.SheetTitle != "" { item.A1 = gridRangeToA1(item.SheetTitle, table.Range) + if dataA1, ok := sheetsTableDataRangeA1(item.SheetTitle, table); ok { + item.DataA1 = dataA1 + } } } + item.HasFooter = sheetsTableHasFooter(table) for _, col := range table.ColumnProperties { if col == nil { continue diff --git a/internal/cmd/sheets_table_clear.go b/internal/cmd/sheets_table_clear.go new file mode 100644 index 00000000..e5e21bb3 --- /dev/null +++ b/internal/cmd/sheets_table_clear.go @@ -0,0 +1,109 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsTableClearCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + TableID string `arg:"" name:"tableId" help:"Table ID or table name"` +} + +func (c *SheetsTableClearCmd) 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) + } + dataRange := strings.TrimSpace(table.DataA1) + if dataRange == "" { + return fmt.Errorf("table %q has no data rows to clear", table.TableID) + } + + if dryRunErr := dryRunExit(ctx, flags, "sheets.table.clear", map[string]any{ + "spreadsheet_id": spreadsheetID, + "table_id": table.TableID, + "name": table.Name, + "data_range": dataRange, + "has_footer": table.HasFooter, + }); dryRunErr != nil { + return dryRunErr + } + if flags == nil || !flags.Force { + return usage("sheets table clear requires --force") + } + if confirmErr := confirmDestructiveChecked(ctx, flagsWithoutDryRun(flags), "clear data rows in table "+table.Name); confirmErr != nil { + return confirmErr + } + + resp, err := svc.Spreadsheets.Values.Clear(spreadsheetID, dataRange, &sheets.ClearValuesRequest{}).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "tableId": table.TableID, + "name": table.Name, + "tableRange": table.A1, + "clearedRange": resp.ClearedRange, + "hasFooter": table.HasFooter, + }) + } + + u.Out().Printf("Cleared data rows in %s", resp.ClearedRange) + return nil +} + +func sheetsTableHasFooter(table *sheets.Table) bool { + return table != nil && table.RowsProperties != nil && table.RowsProperties.FooterColorStyle != nil +} + +func sheetsTableDataRangeA1(sheetTitle string, table *sheets.Table) (string, bool) { + if table == nil || table.Range == nil { + return "", false + } + dataRange := *table.Range + dataRange.StartRowIndex++ + if sheetsTableHasFooter(table) { + dataRange.EndRowIndex-- + } + if dataRange.EndRowIndex <= dataRange.StartRowIndex { + return "", false + } + return gridRangeToA1(sheetTitle, &dataRange), true +} diff --git a/internal/cmd/sheets_table_test.go b/internal/cmd/sheets_table_test.go index 73b037f9..da2bb902 100644 --- a/internal/cmd/sheets_table_test.go +++ b/internal/cmd/sheets_table_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" @@ -326,6 +327,117 @@ func TestSheetsTableAppendRejectsTooWideRows(t *testing.T) { } } +func TestSheetsTableClearCmdClearsDataRowsOnly(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var gotClearRange 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, + }, + }, + }, + }, + }, + }) + case strings.Contains(path, "/spreadsheets/s1/values/") && strings.Contains(path, ":clear") && r.Method == http.MethodPost: + encodedRange := strings.TrimSuffix(strings.TrimPrefix(path, "/spreadsheets/s1/values/"), ":clear") + decodedRange, err := url.PathUnescape(encodedRange) + if err != nil { + t.Fatalf("decode clear range: %v", err) + } + gotClearRange = decodedRange + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"clearedRange": decodedRange}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + installSheetsTestService(t, srv) + + if err := (&SheetsTableClearCmd{SpreadsheetID: "s1", TableID: "tbl1"}).Run(newCmdJSONContext(t), &RootFlags{Account: "a@b.com"}); err == nil { + t.Fatal("expected --force error") + } else if !strings.Contains(err.Error(), "requires --force") { + t.Fatalf("error = %q", err.Error()) + } + if gotClearRange != "" { + t.Fatalf("clear ran without --force: %q", gotClearRange) + } + + out := captureStdout(t, func() { + cmd := &SheetsTableClearCmd{} + if err := runKong(t, cmd, []string{"s1", "tbl1"}, newCmdJSONContext(t), &RootFlags{Account: "a@b.com", Force: true}); err != nil { + t.Fatalf("clear table: %v", err) + } + }) + + if gotClearRange != "Sheet1!A2:C4" { + t.Fatalf("clear range = %q", gotClearRange) + } + if !strings.Contains(out, `"clearedRange": "Sheet1!A2:C4"`) || !strings.Contains(out, `"tableId": "tbl1"`) { + t.Fatalf("missing clear output: %s", out) + } +} + +func TestSheetsTableDataRangeSkipsFooter(t *testing.T) { + table := &sheets.Table{ + Range: &sheets.GridRange{ + SheetId: 42, + StartRowIndex: 0, + EndRowIndex: 5, + StartColumnIndex: 0, + EndColumnIndex: 3, + }, + RowsProperties: &sheets.TableRowsProperties{ + FooterColorStyle: &sheets.ColorStyle{ + RgbColor: &sheets.Color{Red: 1}, + }, + }, + } + got, ok := sheetsTableDataRangeA1("Sheet1", table) + if !ok { + t.Fatal("expected data range") + } + if got != "Sheet1!A2:C4" { + t.Fatalf("data range = %q", got) + } +} + +func TestSheetsTableDataRangeRejectsHeaderOnly(t *testing.T) { + table := &sheets.Table{ + Range: &sheets.GridRange{ + SheetId: 42, + StartRowIndex: 0, + EndRowIndex: 1, + StartColumnIndex: 0, + EndColumnIndex: 3, + }, + } + if got, ok := sheetsTableDataRangeA1("Sheet1", table); ok || got != "" { + t.Fatalf("data range = %q, %v; want empty false", got, ok) + } +} + func installSheetsTestService(t *testing.T, srv *httptest.Server) { t.Helper()