diff --git a/.agents/skills/gog/SKILL.md b/.agents/skills/gog/SKILL.md index a10d6bcfa..51dd8af9f 100644 --- a/.agents/skills/gog/SKILL.md +++ b/.agents/skills/gog/SKILL.md @@ -94,6 +94,7 @@ gog --account user@example.com calendar events --today --json --wrap-untrusted gog --account user@example.com drive ls --max 20 --json --wrap-untrusted gog --account user@example.com docs cat --json --wrap-untrusted gog --account user@example.com sheets get Sheet1!A1:D20 --json --wrap-untrusted +gog --account user@example.com sheets batch-update --data-json @updates.json --json gog --account user@example.com contacts list --max 20 --json --wrap-untrusted ``` @@ -108,12 +109,17 @@ commands that support `--dry-run`, and clean up disposable live-test objects. ```bash gog --account user@example.com docs write --append --text '...' gog --account user@example.com sheets update Sheet1!A1 --values-json '[["hello"]]' +gog --account user@example.com sheets batch-update --data-json @updates.json gog --account user@example.com drive upload ./file.txt --parent --json ``` When testing creation commands, name artifacts with a clear temporary prefix and delete or trash them after verification. +For larger Sheets writes, prefer `sheets batch-update` over loops of +`sheets update`; it sends multiple value ranges in one Sheets API request and +accepts inline JSON or `@file` input. + ## Discovery Use generated command docs and schema instead of guessing flags: diff --git a/CHANGELOG.md b/CHANGELOG.md index c28c97975..2c4c0a35d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Calendar: add --with-zoom / --regenerate-zoom / --remove-zoom that create, regenerate, and remove Zoom meetings and attach the join URL + meeting ID + passcode to the Calendar event description. Google's Calendar API rejects conferenceData writes asserting `conferenceSolution.key.type="addOn"` from non-Workspace-Marketplace OAuth clients, so the description-mode integration is the path that round-trips through Google's storage; trade-off is no native "Join with Zoom" conference card. (#589, #590) — thanks @alexisperumal and @mvanhorn. - Auth: add gog zoom auth setup / doctor for Zoom S2S OAuth credential storage. (#590) — thanks @mvanhorn. +- Sheets: add `sheets batch-update` for updating multiple value ranges in one Sheets API request. (#601) ### Fixed diff --git a/README.md b/README.md index 9ad334e00..bfcc1f977 100644 --- a/README.md +++ b/README.md @@ -268,12 +268,14 @@ gog docs raw --pretty ### Sheets -Docs: [Sheets tables](docs/sheets-tables.md), +Docs: [Sheets batch updates](docs/sheets-batch-update.md), +[Sheets tables](docs/sheets-tables.md), [Sheets formatting](docs/sheets-formatting.md), [`gog sheets`](docs/commands/gog-sheets.md). ```bash gog sheets get 'Sheet1!A1:D20' --json +gog sheets batch-update --data-json @updates.json --json gog sheets table list gog sheets table append Tasks 'Ship README|done' gog sheets table clear Tasks diff --git a/docs/commands.generated.md b/docs/commands.generated.md index f12cda046..d46dd2a7e 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -464,6 +464,7 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) banding (banded-ranges) clear (delete,rm,remove) [flags]`](commands/gog-sheets-banding-clear.md) - Remove alternating color banding - [`gog sheets (sheet) banding (banded-ranges) list [flags]`](commands/gog-sheets-banding-list.md) - List alternating color banded ranges - [`gog sheets (sheet) banding (banded-ranges) set (add,create) [flags]`](commands/gog-sheets-banding-set.md) - Apply alternating colors to a range + - [`gog sheets (sheet) batch-update (batch) --data-json=STRING [flags]`](commands/gog-sheets-batch-update.md) - Update values in multiple ranges with one API request - [`gog sheets (sheet) chart (charts) `](commands/gog-sheets-chart.md) - Manage spreadsheet charts - [`gog sheets (sheet) chart (charts) create (add,new) --spec-json=STRING [flags]`](commands/gog-sheets-chart-create.md) - Create a chart from a JSON spec - [`gog sheets (sheet) chart (charts) delete (rm,remove,del) `](commands/gog-sheets-chart-delete.md) - Delete a chart diff --git a/docs/commands/README.md b/docs/commands/README.md index b512ffcdf..283629679 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: 564. +Generated pages: 565. ## Top-level Commands @@ -515,6 +515,7 @@ Generated pages: 564. - [gog sheets banding clear](gog-sheets-banding-clear.md) - Remove alternating color banding - [gog sheets banding list](gog-sheets-banding-list.md) - List alternating color banded ranges - [gog sheets banding set](gog-sheets-banding-set.md) - Apply alternating colors to a range + - [gog sheets batch-update](gog-sheets-batch-update.md) - Update values in multiple ranges with one API request - [gog sheets chart](gog-sheets-chart.md) - Manage spreadsheet charts - [gog sheets chart create](gog-sheets-chart-create.md) - Create a chart from a JSON spec - [gog sheets chart delete](gog-sheets-chart-delete.md) - Delete a chart diff --git a/docs/commands/gog-sheets-batch-update.md b/docs/commands/gog-sheets-batch-update.md new file mode 100644 index 000000000..7a415c4f3 --- /dev/null +++ b/docs/commands/gog-sheets-batch-update.md @@ -0,0 +1,48 @@ +# `gog sheets batch-update` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Update values in multiple ranges with one API request + +## Usage + +```bash +gog sheets (sheet) batch-update (batch) --data-json=STRING [flags] +``` + +## Parent + +- [gog sheets](gog-sheets.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/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--data-json` | `string` | | Value ranges as JSON array, or @file (e.g. [{"range":"Sheet1!A1:B2","values":[["a","b"]]}]) | +| `--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. | +| `--include-values-in-response` | `bool` | | Include updated values in the response | +| `--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) | +| `--response-date-time-render` | `string` | | Response date/time render option: SERIAL_NUMBER or FORMATTED_STRING | +| `--response-render` | `string` | | Response value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA | +| `--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 | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## 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 7667172e0..a24451198 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -19,6 +19,7 @@ gog sheets (sheet) [flags] - [gog sheets add-tab](gog-sheets-add-tab.md) - Add a new tab/sheet to a spreadsheet - [gog sheets append](gog-sheets-append.md) - Append values to a range - [gog sheets banding](gog-sheets-banding.md) - Manage alternating color banding +- [gog sheets batch-update](gog-sheets-batch-update.md) - Update values in multiple ranges with one API request - [gog sheets chart](gog-sheets-chart.md) - Manage spreadsheet charts - [gog sheets clear](gog-sheets-clear.md) - Clear values in a range - [gog sheets conditional-format](gog-sheets-conditional-format.md) - Manage conditional formatting rules diff --git a/docs/sheets-batch-update.md b/docs/sheets-batch-update.md new file mode 100644 index 000000000..2d59f35ca --- /dev/null +++ b/docs/sheets-batch-update.md @@ -0,0 +1,54 @@ +# Sheets Batch Updates + +Use `gog sheets batch-update` when you need to update multiple ranges in the +same spreadsheet without making one API call per range. The command sends a +single Google Sheets `spreadsheets.values.batchUpdate` request. + +Prepare a JSON array of value ranges: + +```json +[ + { + "range": "Sheet1!A1:B1", + "values": [["Name", "Status"]] + }, + { + "range": "Sheet1!A2:B3", + "values": [ + ["Ada", "Ready"], + ["Grace", "Blocked"] + ] + } +] +``` + +Then pass it inline or from a file: + +```bash +gog sheets batch-update "$spreadsheet_id" --data-json @updates.json --json +``` + +By default, values are interpreted as if they were entered in the Google Sheets +UI (`USER_ENTERED`). Use `--input RAW` to store values without parsing: + +```bash +gog sheets batch-update "$spreadsheet_id" \ + --input RAW \ + --data-json '[{"range":"Sheet1!A1:B1","values":[["001","plain text"]]}]' +``` + +Add `--include-values-in-response` when callers need the post-update cell values +back from Google: + +```bash +gog sheets batch-update "$spreadsheet_id" \ + --include-values-in-response \ + --response-render UNFORMATTED_VALUE \ + --data-json @updates.json \ + --json +``` + +Related command reference: + +- [`gog sheets batch-update`](commands/gog-sheets-batch-update.md) +- [`gog sheets update`](commands/gog-sheets-update.md) diff --git a/internal/cmd/docs_format_test.go b/internal/cmd/docs_format_test.go index 14e33ede4..ba2be5dd2 100644 --- a/internal/cmd/docs_format_test.go +++ b/internal/cmd/docs_format_test.go @@ -31,6 +31,7 @@ func TestDocsFormatFlagsBuildRequests(t *testing.T) { textReq := reqs[0].UpdateTextStyle if textReq == nil { t.Fatalf("missing text request: %#v", reqs[0]) + return } if got := textReq.Range; got.StartIndex != 3 || got.EndIndex != 9 || got.TabId != "t.second" { t.Fatalf("unexpected text range: %#v", got) @@ -56,6 +57,7 @@ func TestDocsFormatFlagsBuildRequests(t *testing.T) { paraReq := reqs[1].UpdateParagraphStyle if paraReq == nil { t.Fatalf("missing paragraph request: %#v", reqs[1]) + return } if paraReq.ParagraphStyle.Alignment != "CENTER" || paraReq.ParagraphStyle.LineSpacing != 150 { t.Fatalf("unexpected paragraph style: %#v", paraReq.ParagraphStyle) diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 5ee4d2637..fe29f63b3 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -28,6 +28,7 @@ func cleanRange(r string) string { type SheetsCmd struct { Get SheetsGetCmd `cmd:"" name:"get" aliases:"read,show" help:"Get values from a range"` Update SheetsUpdateCmd `cmd:"" name:"update" aliases:"edit,set" help:"Update values in a range"` + BatchUpdate SheetsBatchUpdateCmd `cmd:"" name:"batch-update" aliases:"batch" help:"Update values in multiple ranges with one API request"` Append SheetsAppendCmd `cmd:"" name:"append" aliases:"add" help:"Append values to a range"` Insert SheetsInsertCmd `cmd:"" name:"insert" help:"Insert empty rows or columns into a sheet"` Clear SheetsClearCmd `cmd:"" name:"clear" help:"Clear values in a range"` @@ -262,6 +263,115 @@ func (c *SheetsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } +type SheetsBatchUpdateCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + DataJSON string `name:"data-json" required:"" help:"Value ranges as JSON array, or @file (e.g. [{\"range\":\"Sheet1!A1:B2\",\"values\":[[\"a\",\"b\"]]}])"` + ValueInput string `name:"input" help:"Value input option: RAW or USER_ENTERED" default:"USER_ENTERED"` + IncludeValuesInResponse bool `name:"include-values-in-response" help:"Include updated values in the response"` + ResponseValueRenderOption string `name:"response-render" help:"Response value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA"` + ResponseDateTimeRenderOption string `name:"response-date-time-render" help:"Response date/time render option: SERIAL_NUMBER or FORMATTED_STRING"` +} + +func (c *SheetsBatchUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + data, err := parseSheetsBatchUpdateData(c.DataJSON) + if err != nil { + return err + } + + valueInputOption := strings.TrimSpace(c.ValueInput) + if valueInputOption == "" { + valueInputOption = sheetsDefaultValueInputOption + } + req := &sheets.BatchUpdateValuesRequest{ + Data: data, + ValueInputOption: valueInputOption, + IncludeValuesInResponse: c.IncludeValuesInResponse, + } + if strings.TrimSpace(c.ResponseValueRenderOption) != "" { + req.ResponseValueRenderOption = strings.TrimSpace(c.ResponseValueRenderOption) + } + if strings.TrimSpace(c.ResponseDateTimeRenderOption) != "" { + req.ResponseDateTimeRenderOption = strings.TrimSpace(c.ResponseDateTimeRenderOption) + } + + if dryRunErr := dryRunExit(ctx, flags, "sheets.batch-update", map[string]any{ + "spreadsheet_id": spreadsheetID, + "value_input_option": req.ValueInputOption, + "include_values_in_response": req.IncludeValuesInResponse, + "response_value_render_option": req.ResponseValueRenderOption, + "response_date_time_render_option": req.ResponseDateTimeRenderOption, + "data": req.Data, + }); dryRunErr != nil { + return dryRunErr + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + resp, err := svc.Spreadsheets.Values.BatchUpdate(spreadsheetID, req).Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": resp.SpreadsheetId, + "totalUpdatedRows": resp.TotalUpdatedRows, + "totalUpdatedColumns": resp.TotalUpdatedColumns, + "totalUpdatedCells": resp.TotalUpdatedCells, + "totalUpdatedSheets": resp.TotalUpdatedSheets, + "responses": resp.Responses, + }) + } + + u.Out().Linef("Updated %d cells across %d ranges in %s", resp.TotalUpdatedCells, len(resp.Responses), spreadsheetID) + return nil +} + +func parseSheetsBatchUpdateData(dataJSON string) ([]*sheets.ValueRange, error) { + if strings.TrimSpace(dataJSON) == "" { + return nil, usage("empty data-json") + } + b, err := resolveInlineOrFileBytes(dataJSON) + if err != nil { + return nil, fmt.Errorf("read --data-json: %w", err) + } + var data []*sheets.ValueRange + if unmarshalErr := json.Unmarshal(b, &data); unmarshalErr != nil { + return nil, fmt.Errorf("invalid JSON data: %w", unmarshalErr) + } + if len(data) == 0 { + return nil, usage("--data-json must contain at least one value range") + } + for i, vr := range data { + if vr == nil { + return nil, usagef("--data-json range %d is null", i) + } + vr.Range = cleanRange(vr.Range) + if strings.TrimSpace(vr.Range) == "" { + return nil, usagef("--data-json range %d has empty range", i) + } + if vr.Values == nil { + return nil, usagef("--data-json range %d has empty values", i) + } + } + return data, nil +} + type SheetsAppendCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` Range string `arg:"" name:"range" help:"Range (A1 notation or named range name; e.g. Sheet1!A:C or MyNamedRange)"` diff --git a/internal/cmd/sheets_advanced_test.go b/internal/cmd/sheets_advanced_test.go index 806d6536e..9a59323d1 100644 --- a/internal/cmd/sheets_advanced_test.go +++ b/internal/cmd/sheets_advanced_test.go @@ -84,6 +84,7 @@ func TestSheetsConditionalClearAllDeletesReverseAndRequiresForce(t *testing.T) { second := (*requests)[0].Requests[1].DeleteConditionalFormatRule if first == nil || second == nil { t.Fatalf("missing deleteConditionalFormatRule: %#v", (*requests)[0].Requests) + return } if first.Index != 1 || second.Index != 0 { t.Fatalf("delete indexes = %d,%d; want 1,0", first.Index, second.Index) diff --git a/internal/cmd/sheets_batch_update_test.go b/internal/cmd/sheets_batch_update_test.go new file mode 100644 index 000000000..dde483306 --- /dev/null +++ b/internal/cmd/sheets_batch_update_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" +) + +func TestSheetsBatchUpdateCmd_JSON(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var gotReq sheets.BatchUpdateValuesRequest + var gotPath 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") + gotPath = path + switch { + case path == "/spreadsheets/s1/values:batchUpdate" && r.Method == http.MethodPost: + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("decode batchUpdate values: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "totalUpdatedRows": 2, + "totalUpdatedColumns": 2, + "totalUpdatedCells": 4, + "totalUpdatedSheets": 1, + "responses": []map[string]any{ + { + "updatedRange": "Sheet1!A1:B1", + "updatedRows": 1, + "updatedColumns": 2, + "updatedCells": 2, + }, + { + "updatedRange": "Sheet1!A2:B2", + "updatedRows": 1, + "updatedColumns": 2, + "updatedCells": 2, + }, + }, + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + 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 } + + out := captureStdout(t, func() { + err := runKong(t, &SheetsBatchUpdateCmd{}, []string{ + "s1", + "--input", "RAW", + "--include-values-in-response", + "--response-render", "UNFORMATTED_VALUE", + "--data-json", `[ + {"range":"Sheet1\\!A1:B1","values":[["a","b"]]}, + {"range":"Sheet1!A2:B2","values":[["c","d"]]} + ]`, + }, newCmdJSONContext(t), &RootFlags{Account: "a@b.com"}) + if err != nil { + t.Fatalf("batch update: %v", err) + } + }) + + if gotPath != "/spreadsheets/s1/values:batchUpdate" { + t.Fatalf("unexpected request path: %q", gotPath) + } + if gotReq.ValueInputOption != "RAW" { + t.Fatalf("ValueInputOption = %q, want RAW", gotReq.ValueInputOption) + } + if !gotReq.IncludeValuesInResponse { + t.Fatal("expected IncludeValuesInResponse") + } + if gotReq.ResponseValueRenderOption != "UNFORMATTED_VALUE" { + t.Fatalf("ResponseValueRenderOption = %q", gotReq.ResponseValueRenderOption) + } + if len(gotReq.Data) != 2 { + t.Fatalf("expected 2 value ranges, got %d", len(gotReq.Data)) + } + if gotReq.Data[0].Range != "Sheet1!A1:B1" { + t.Fatalf("range was not cleaned: %q", gotReq.Data[0].Range) + } + if got := gotReq.Data[1].Values[0][1]; got != "d" { + t.Fatalf("unexpected value: %#v", got) + } + + var payload struct { + SpreadsheetID string `json:"spreadsheetId"` + TotalUpdatedRows int64 `json:"totalUpdatedRows"` + TotalUpdatedColumns int64 `json:"totalUpdatedColumns"` + TotalUpdatedCells int64 `json:"totalUpdatedCells"` + TotalUpdatedSheets int64 `json:"totalUpdatedSheets"` + Responses []struct { + UpdatedRange string `json:"updatedRange"` + } `json:"responses"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode output: %v\nout=%s", err, out) + } + if payload.SpreadsheetID != "s1" || payload.TotalUpdatedCells != 4 || len(payload.Responses) != 2 { + t.Fatalf("unexpected output: %#v", payload) + } +} + +func TestSheetsBatchUpdateCmd_DryRunSkipsService(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + newSheetsService = func(context.Context, string) (*sheets.Service, error) { + t.Fatal("newSheetsService should not be called during dry-run") + return nil, errors.New("unexpected sheets service call") + } + + out := captureStdout(t, func() { + err := runKong(t, &SheetsBatchUpdateCmd{}, []string{ + "s1", + "--data-json", `[{"range":"Sheet1!A1","values":[["a"]]}]`, + }, newCmdJSONContext(t), &RootFlags{DryRun: true, NoInput: true}) + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 0 { + t.Fatalf("dry-run batch update: %v", err) + } + }) + + var payload struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + Request struct { + SpreadsheetID string `json:"spreadsheet_id"` + Data []struct { + Range string `json:"range"` + Values [][]interface{} `json:"values"` + } `json:"data"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode dry-run: %v\nout=%s", err, out) + } + if !payload.DryRun || payload.Op != "sheets.batch-update" || payload.Request.SpreadsheetID != "s1" { + t.Fatalf("unexpected dry-run payload: %#v", payload) + } + if len(payload.Request.Data) != 1 || payload.Request.Data[0].Range != "Sheet1!A1" { + t.Fatalf("unexpected dry-run data: %#v", payload.Request.Data) + } +} + +func TestParseSheetsBatchUpdateDataRejectsInvalidPayloads(t *testing.T) { + for _, tc := range []struct { + name string + in string + want string + }{ + {name: "empty array", in: `[]`, want: "at least one value range"}, + {name: "null range", in: `[null]`, want: "range 0 is null"}, + {name: "empty range", in: `[{"range":"","values":[["a"]]}]`, want: "empty range"}, + {name: "missing values", in: `[{"range":"Sheet1!A1"}]`, want: "empty values"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := parseSheetsBatchUpdateData(tc.in) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + }) + } +} diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml index ced1273c0..eab8bc9be 100644 --- a/safety-profiles/agent-safe.yaml +++ b/safety-profiles/agent-safe.yaml @@ -176,6 +176,7 @@ sheets: read-format: true export: true update: false + batch-update: false append: false insert: false clear: false diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml index 77dda0325..8d660f4a3 100644 --- a/safety-profiles/readonly.yaml +++ b/safety-profiles/readonly.yaml @@ -181,6 +181,7 @@ sheets: read-format: true export: true update: false + batch-update: false append: false insert: false clear: false diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index f400bb93d..ed118276a 100755 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -22,7 +22,7 @@ const sections = [ ["Start", ["index.md", "install.md", "quickstart.md", "auth-clients.md", "workspace-admin.md", "safety-profiles.md"]], ["Gmail", ["gmail-workflows.md", "gmail-autoreply.md", "watch.md", "email-tracking.md", "email-tracking-worker.md"]], ["Drive & Files", ["drive-audits.md", "raw-api.md", "raw-audit.md"]], - ["Docs, Sheets, Slides", ["docs-editing.md", "sedmat.md", "sheets-tables.md", "sheets-formatting.md", "slides-markdown.md", "slides-template-replacement.md"]], + ["Docs, Sheets, Slides", ["docs-editing.md", "sedmat.md", "sheets-batch-update.md", "sheets-tables.md", "sheets-formatting.md", "slides-markdown.md", "slides-template-replacement.md"]], ["Contacts", ["contacts-dedupe.md", "contacts-json-update.md"]], ["Backup", ["backup.md"]], ["Reference", ["dates.md", "spec.md", "RELEASING.md", "commands/README.md"]], diff --git a/scripts/check-docs-coverage.mjs b/scripts/check-docs-coverage.mjs index 511a120b9..0f861cee5 100644 --- a/scripts/check-docs-coverage.mjs +++ b/scripts/check-docs-coverage.mjs @@ -23,6 +23,7 @@ const requiredFeatureDocs = [ "contacts-dedupe.md", "contacts-json-update.md", "docs-editing.md", + "sheets-batch-update.md", "sheets-tables.md", "sheets-formatting.md", "slides-markdown.md", diff --git a/scripts/live-tests/sheets.sh b/scripts/live-tests/sheets.sh index f071f5c63..b5735dbd1 100644 --- a/scripts/live-tests/sheets.sh +++ b/scripts/live-tests/sheets.sh @@ -15,6 +15,7 @@ run_sheets_tests() { run_required "sheets" "sheets metadata" gog sheets metadata "$sheet_id" --json >/dev/null run_required "sheets" "sheets update" gog sheets update "$sheet_id" "Sheet1!A1:B2" --values-json '[["A1","B1"],["A2","B2"]]' --json >/dev/null + run_required "sheets" "sheets batch-update" gog sheets batch-update "$sheet_id" --data-json '[{"range":"Sheet1!C1:D1","values":[["C1","D1"]]},{"range":"Sheet1!C2:D2","values":[["C2","D2"]]}]' --json >/dev/null run_required "sheets" "sheets get" gog sheets get "$sheet_id" "Sheet1!A1:B2" --json >/dev/null run_required "sheets" "sheets append" gog sheets append "$sheet_id" "Sheet1!A3:B3" --values-json '[["A3","B3"]]' --json >/dev/null run_required "sheets" "sheets format" gog sheets format "$sheet_id" "Sheet1!A1:B1" --format-json '{"textFormat":{"bold":true}}' --format-fields textFormat.bold --json >/dev/null