feat(sheets): clear table data rows
Adds header-safe table data row clearing for Google Sheets tables, including --force enforcement, footer-skip range calculation, docs, tests, and live Google smoke verification.
This commit is contained in:
parent
5d3b1c0980
commit
aa7c0a2f90
@ -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.
|
||||
|
||||
@ -1392,6 +1392,7 @@ gog sheets named-ranges delete <spreadsheetId> MyNamedRange2
|
||||
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 clear <spreadsheetId> <tableId> --force
|
||||
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.
|
||||
|
||||
@ -416,6 +416,7 @@ Generated from `gog schema --json`.
|
||||
- [`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) clear (clear-rows) <spreadsheetId> <tableId>`](commands/gog-sheets-table-clear.md) - Clear table data rows
|
||||
- [`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
|
||||
|
||||
@ -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
|
||||
|
||||
42
docs/commands/gog-sheets-table-clear.md
Normal file
42
docs/commands/gog-sheets-table-clear.md
Normal file
@ -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) <spreadsheetId> <tableId>
|
||||
```
|
||||
|
||||
## 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. |
|
||||
| `-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. |
|
||||
| `-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)
|
||||
@ -17,6 +17,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 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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
109
internal/cmd/sheets_table_clear.go
Normal file
109
internal/cmd/sheets_table_clear.go
Normal file
@ -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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user