From 1e2ab54fc92224860047c426790fbf2968b2a7f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 02:07:24 +0100 Subject: [PATCH] feat(sheets): add conditional formatting and banding Co-authored-by: gobang <50824182+codBang@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 9 +- docs/commands.generated.md | 8 + docs/commands/README.md | 10 +- docs/commands/gog-sheets-banding-clear.md | 45 ++ docs/commands/gog-sheets-banding-list.md | 43 ++ docs/commands/gog-sheets-banding-set.md | 44 ++ docs/commands/gog-sheets-banding.md | 48 +++ .../gog-sheets-conditional-format-add.md | 47 +++ .../gog-sheets-conditional-format-clear.md | 45 ++ .../gog-sheets-conditional-format-list.md | 43 ++ .../commands/gog-sheets-conditional-format.md | 48 +++ docs/commands/gog-sheets.md | 2 + docs/sheets-formatting.md | 88 ++++ internal/cmd/sheets.go | 2 + internal/cmd/sheets_advanced_test.go | 267 ++++++++++++ internal/cmd/sheets_banding.go | 349 +++++++++++++++ internal/cmd/sheets_conditional.go | 398 ++++++++++++++++++ 18 files changed, 1495 insertions(+), 2 deletions(-) create mode 100644 docs/commands/gog-sheets-banding-clear.md create mode 100644 docs/commands/gog-sheets-banding-list.md create mode 100644 docs/commands/gog-sheets-banding-set.md create mode 100644 docs/commands/gog-sheets-banding.md create mode 100644 docs/commands/gog-sheets-conditional-format-add.md create mode 100644 docs/commands/gog-sheets-conditional-format-clear.md create mode 100644 docs/commands/gog-sheets-conditional-format-list.md create mode 100644 docs/commands/gog-sheets-conditional-format.md create mode 100644 docs/sheets-formatting.md create mode 100644 internal/cmd/sheets_advanced_test.go create mode 100644 internal/cmd/sheets_banding.go create mode 100644 internal/cmd/sheets_conditional.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 686846c5..af07893a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 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. +- Sheets: add `sheets conditional-format` and `sheets banding` commands for rule-based formatting and alternating color banded ranges. (#378) — thanks @codBang. ### 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 5dc57b0e..3f61d342 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Drive** - list/search/upload/download files, scope search to folders or shared drives, replace uploads in-place, convert uploads (including Markdown to Google Doc), manage permissions/comments, organize folders, and list shared drives - **Contacts** - search/create/update contacts, including addresses, relations, org/title metadata, custom fields, Workspace directory, and other contacts - **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, plus repeat schedule materialization with RRULE aliases -- **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs, named ranges, and Sheets tables, format/merge/freeze/resize cells, read/write notes, inspect formats, find/replace text, list links, and create/export sheets +- **Sheets** - read/write/update spreadsheets, insert rows/cols, manage tabs, named ranges, and Sheets tables, format/merge/freeze/resize cells, manage conditional formatting and banding, read/write notes, inspect formats, find/replace text, list links, and create/export sheets - **Forms** - create/update forms, manage questions, inspect responses, and manage watches - **Apps Script** - create/get/bind projects, inspect content, and run functions - **Docs/Slides** - create/copy/export docs/slides, edit Docs by tab title or ID, import Markdown, do richer find-replace, export whole Docs or a single Docs tab, and generate Slides from Markdown or templates @@ -1379,6 +1379,13 @@ gog sheets resize-columns 'Sheet1!A:C' --auto gog sheets resize-rows 'Sheet1!1:10' --height 36 gog sheets read-format 'Sheet1!A1:B2' gog sheets read-format 'Sheet1!A1:B2' --effective +gog sheets conditional-format add 'Sheet1!A2:C' --type text-eq --expr done --format-json '{"backgroundColor":{"red":0.85,"green":0.94,"blue":0.82}}' +gog sheets conditional-format list +gog sheets conditional-format clear --sheet Sheet1 --all --force +gog sheets banding set 'Sheet1!A1:C20' +gog sheets banding list +gog sheets banding clear --sheet Sheet1 --all --force +# See docs/sheets-formatting.md for conditional format and banding details. # Named ranges gog sheets named-ranges diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 186c350d..b3576b93 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -382,6 +382,10 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) [flags]`](commands/gog-sheets.md) - Google Sheets - [`gog sheets (sheet) add-tab (add-sheet) [flags]`](commands/gog-sheets-add-tab.md) - Add a new tab/sheet to a spreadsheet - [`gog sheets (sheet) append (add) [ ...] [flags]`](commands/gog-sheets-append.md) - Append values to a range + - [`gog sheets (sheet) banding (banded-ranges) `](commands/gog-sheets-banding.md) - Manage alternating color banding + - [`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) 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 @@ -389,6 +393,10 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) chart (charts) list `](commands/gog-sheets-chart-list.md) - List charts in a spreadsheet - [`gog sheets (sheet) chart (charts) update (edit,set) --spec-json=STRING `](commands/gog-sheets-chart-update.md) - Update a chart spec - [`gog sheets (sheet) clear `](commands/gog-sheets-clear.md) - Clear values in a range + - [`gog sheets (sheet) conditional-format (cf,conditional-formats) `](commands/gog-sheets-conditional-format.md) - Manage conditional formatting rules + - [`gog sheets (sheet) conditional-format (cf,conditional-formats) add (create,new) --type=STRING --format-json=STRING [flags]`](commands/gog-sheets-conditional-format-add.md) - Add a conditional formatting rule + - [`gog sheets (sheet) conditional-format (cf,conditional-formats) clear (delete,rm,remove) --sheet=STRING [flags]`](commands/gog-sheets-conditional-format-clear.md) - Remove conditional formatting rules + - [`gog sheets (sheet) conditional-format (cf,conditional-formats) list [flags]`](commands/gog-sheets-conditional-format-list.md) - List conditional formatting rules - [`gog sheets (sheet) copy (cp,duplicate) [flags]`](commands/gog-sheets-copy.md) - Copy a Google Sheet - [`gog sheets (sheet) create (new) <title> [flags]`](commands/gog-sheets-create.md) - Create a new spreadsheet - [`gog sheets (sheet) delete-tab (delete-sheet) <spreadsheetId> <tabName>`](commands/gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) diff --git a/docs/commands/README.md b/docs/commands/README.md index 14c95943..08e43ea3 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: 458. +Generated pages: 466. ## Top-level Commands @@ -425,6 +425,10 @@ Generated pages: 458. - [gog sheets](gog-sheets.md) - Google Sheets - [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 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 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 @@ -432,6 +436,10 @@ Generated pages: 458. - [gog sheets chart list](gog-sheets-chart-list.md) - List charts in a spreadsheet - [gog sheets chart update](gog-sheets-chart-update.md) - Update a chart spec - [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 + - [gog sheets conditional-format add](gog-sheets-conditional-format-add.md) - Add a conditional formatting rule + - [gog sheets conditional-format clear](gog-sheets-conditional-format-clear.md) - Remove conditional formatting rules + - [gog sheets conditional-format list](gog-sheets-conditional-format-list.md) - List conditional formatting rules - [gog sheets copy](gog-sheets-copy.md) - Copy a Google Sheet - [gog sheets create](gog-sheets-create.md) - Create a new spreadsheet - [gog sheets delete-tab](gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) diff --git a/docs/commands/gog-sheets-banding-clear.md b/docs/commands/gog-sheets-banding-clear.md new file mode 100644 index 00000000..201341d5 --- /dev/null +++ b/docs/commands/gog-sheets-banding-clear.md @@ -0,0 +1,45 @@ +# `gog sheets banding clear` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Remove alternating color banding + +## Usage + +```bash +gog sheets (sheet) banding (banded-ranges) clear (delete,rm,remove) <spreadsheetId> [flags] +``` + +## Parent + +- [gog sheets banding](gog-sheets-banding.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) | +| `--all` | `bool` | | Remove all banding from the sheet | +| `--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. | +| `--id` | `int64` | | Banded range ID to remove | +| `-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. | +| `--sheet` | `string` | | Sheet name for --all | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets banding](gog-sheets-banding.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-banding-list.md b/docs/commands/gog-sheets-banding-list.md new file mode 100644 index 00000000..52248a62 --- /dev/null +++ b/docs/commands/gog-sheets-banding-list.md @@ -0,0 +1,43 @@ +# `gog sheets banding list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List alternating color banded ranges + +## Usage + +```bash +gog sheets (sheet) banding (banded-ranges) list <spreadsheetId> [flags] +``` + +## Parent + +- [gog sheets banding](gog-sheets-banding.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. | +| `--sheet` | `string` | | Only list banding from this sheet | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets banding](gog-sheets-banding.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-banding-set.md b/docs/commands/gog-sheets-banding-set.md new file mode 100644 index 00000000..09c30296 --- /dev/null +++ b/docs/commands/gog-sheets-banding-set.md @@ -0,0 +1,44 @@ +# `gog sheets banding set` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Apply alternating colors to a range + +## Usage + +```bash +gog sheets (sheet) banding (banded-ranges) set (add,create) <spreadsheetId> <range> [flags] +``` + +## Parent + +- [gog sheets banding](gog-sheets-banding.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 | +| `--column-properties-json` | `string` | | Sheets API BandingProperties JSON for column colors | +| `--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) | +| `--row-properties-json` | `string` | | Sheets API BandingProperties JSON for row colors | +| `--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 banding](gog-sheets-banding.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-banding.md b/docs/commands/gog-sheets-banding.md new file mode 100644 index 00000000..29850624 --- /dev/null +++ b/docs/commands/gog-sheets-banding.md @@ -0,0 +1,48 @@ +# `gog sheets banding` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Manage alternating color banding + +## Usage + +```bash +gog sheets (sheet) banding (banded-ranges) <command> +``` + +## Parent + +- [gog sheets](gog-sheets.md) + +## Subcommands + +- [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 + +## 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](gog-sheets.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-conditional-format-add.md b/docs/commands/gog-sheets-conditional-format-add.md new file mode 100644 index 00000000..4aeb240a --- /dev/null +++ b/docs/commands/gog-sheets-conditional-format-add.md @@ -0,0 +1,47 @@ +# `gog sheets conditional-format add` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Add a conditional formatting rule + +## Usage + +```bash +gog sheets (sheet) conditional-format (cf,conditional-formats) add (create,new) --type=STRING --format-json=STRING <spreadsheetId> <range> [flags] +``` + +## Parent + +- [gog sheets conditional-format](gog-sheets-conditional-format.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) | +| `--expr` | `string` | | Expression value or custom formula (omit for blank/not-blank) | +| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--format-fields` | `string` | | Format field mask for force-sending zero/false fields (e.g. backgroundColor,textFormat.bold) | +| `--format-json` | `string` | | CellFormat JSON (inline or @file) | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--index` | `int64` | 0 | Insert rule at this priority index | +| `-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. | +| `--type` | `string` | | Rule type: text-eq\|text-contains\|text-starts-with\|text-ends-with\|number-eq\|number-gt\|number-gte\|number-lt\|number-lte\|blank\|not-blank\|custom-formula | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets conditional-format](gog-sheets-conditional-format.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-conditional-format-clear.md b/docs/commands/gog-sheets-conditional-format-clear.md new file mode 100644 index 00000000..e7aea43a --- /dev/null +++ b/docs/commands/gog-sheets-conditional-format-clear.md @@ -0,0 +1,45 @@ +# `gog sheets conditional-format clear` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Remove conditional formatting rules + +## Usage + +```bash +gog sheets (sheet) conditional-format (cf,conditional-formats) clear (delete,rm,remove) --sheet=STRING <spreadsheetId> [flags] +``` + +## Parent + +- [gog sheets conditional-format](gog-sheets-conditional-format.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) | +| `--all` | `bool` | | Remove all conditional formatting rules from the sheet | +| `--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. | +| `--index` | `string` | | Rule index to remove | +| `-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. | +| `--sheet` | `string` | | Sheet name | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets conditional-format](gog-sheets-conditional-format.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-conditional-format-list.md b/docs/commands/gog-sheets-conditional-format-list.md new file mode 100644 index 00000000..60cbae44 --- /dev/null +++ b/docs/commands/gog-sheets-conditional-format-list.md @@ -0,0 +1,43 @@ +# `gog sheets conditional-format list` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +List conditional formatting rules + +## Usage + +```bash +gog sheets (sheet) conditional-format (cf,conditional-formats) list <spreadsheetId> [flags] +``` + +## Parent + +- [gog sheets conditional-format](gog-sheets-conditional-format.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. | +| `--sheet` | `string` | | Only list rules from this sheet | +| `-v`<br>`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | + +## See Also + +- [gog sheets conditional-format](gog-sheets-conditional-format.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-conditional-format.md b/docs/commands/gog-sheets-conditional-format.md new file mode 100644 index 00000000..89f9852f --- /dev/null +++ b/docs/commands/gog-sheets-conditional-format.md @@ -0,0 +1,48 @@ +# `gog sheets conditional-format` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Manage conditional formatting rules + +## Usage + +```bash +gog sheets (sheet) conditional-format (cf,conditional-formats) <command> +``` + +## Parent + +- [gog sheets](gog-sheets.md) + +## Subcommands + +- [gog sheets conditional-format add](gog-sheets-conditional-format-add.md) - Add a conditional formatting rule +- [gog sheets conditional-format clear](gog-sheets-conditional-format-clear.md) - Remove conditional formatting rules +- [gog sheets conditional-format list](gog-sheets-conditional-format-list.md) - List conditional formatting rules + +## 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](gog-sheets.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets.md b/docs/commands/gog-sheets.md index bbf943fa..ec29010a 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -18,8 +18,10 @@ gog sheets (sheet) <command> [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 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 - [gog sheets copy](gog-sheets-copy.md) - Copy a Google Sheet - [gog sheets create](gog-sheets-create.md) - Create a new spreadsheet - [gog sheets delete-tab](gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) diff --git a/docs/sheets-formatting.md b/docs/sheets-formatting.md new file mode 100644 index 00000000..a8fba88f --- /dev/null +++ b/docs/sheets-formatting.md @@ -0,0 +1,88 @@ +# Sheets Formatting + +read_when: +- Adding or reviewing Google Sheets formatting commands. +- Using conditional formatting or alternating color banding from automation. + +`gog sheets format` applies direct cell formatting. Use the advanced formatting +commands when the spreadsheet should keep applying styling as data changes. + +## Conditional Formats + +Add a rule to a sheet-qualified range: + +```bash +gog sheets conditional-format add "$spreadsheet_id" 'Sheet1!A2:C' \ + --type text-eq \ + --expr done \ + --format-json '{"backgroundColor":{"red":0.85,"green":0.94,"blue":0.82}}' +``` + +Supported rule shortcuts: + +- `text-eq`, `text-contains`, `text-starts-with`, `text-ends-with` +- `number-eq`, `number-gt`, `number-gte`, `number-lt`, `number-lte` +- `blank`, `not-blank` +- `custom-formula` + +Use `--format-fields` when the JSON contains zero or false values that must be +sent explicitly: + +```bash +gog sheets conditional-format add "$spreadsheet_id" 'Sheet1!A2:C' \ + --type custom-formula \ + --expr '=$C2=TRUE' \ + --format-json '{"textFormat":{"bold":false}}' \ + --format-fields textFormat.bold +``` + +List rules: + +```bash +gog sheets conditional-format list "$spreadsheet_id" --json +gog sheets conditional-format list "$spreadsheet_id" --sheet Sheet1 +``` + +Remove one rule by index, or all rules from a sheet: + +```bash +gog sheets conditional-format clear "$spreadsheet_id" --sheet Sheet1 --index 0 --force +gog sheets conditional-format clear "$spreadsheet_id" --sheet Sheet1 --all --force +``` + +`clear --all` deletes from the highest index down so lower indexes do not shift +under the batch request. + +## Banding + +Apply default alternating row colors: + +```bash +gog sheets banding set "$spreadsheet_id" 'Sheet1!A1:C20' +``` + +Override row or column banding with Sheets API `BandingProperties` JSON: + +```bash +gog sheets banding set "$spreadsheet_id" 'Sheet1!A1:C20' \ + --row-properties-json '{"firstBandColorStyle":{"rgbColor":{"red":1,"green":1,"blue":1}},"secondBandColorStyle":{"rgbColor":{"red":0.96,"green":0.98,"blue":1}}}' +``` + +List and clear banded ranges: + +```bash +gog sheets banding list "$spreadsheet_id" --json +gog sheets banding clear "$spreadsheet_id" --id 123456 --force +gog sheets banding clear "$spreadsheet_id" --sheet Sheet1 --all --force +``` + +## Command Pages + +- [`gog sheets conditional-format`](commands/gog-sheets-conditional-format.md) +- [`gog sheets conditional-format add`](commands/gog-sheets-conditional-format-add.md) +- [`gog sheets conditional-format list`](commands/gog-sheets-conditional-format-list.md) +- [`gog sheets conditional-format clear`](commands/gog-sheets-conditional-format-clear.md) +- [`gog sheets banding`](commands/gog-sheets-banding.md) +- [`gog sheets banding set`](commands/gog-sheets-banding-set.md) +- [`gog sheets banding list`](commands/gog-sheets-banding-list.md) +- [`gog sheets banding clear`](commands/gog-sheets-banding-clear.md) diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index a47cedaa..ae4e1eb3 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -33,6 +33,8 @@ type SheetsCmd struct { 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"` Format SheetsFormatCmd `cmd:"" name:"format" help:"Apply cell formatting to a range"` + Conditional SheetsConditionalCmd `cmd:"" name:"conditional-format" aliases:"cf,conditional-formats" help:"Manage conditional formatting rules"` + Banding SheetsBandingCmd `cmd:"" name:"banding" aliases:"banded-ranges" help:"Manage alternating color banding"` Merge SheetsMergeCmd `cmd:"" name:"merge" help:"Merge cells in a range"` Unmerge SheetsUnmergeCmd `cmd:"" name:"unmerge" help:"Unmerge cells in a range"` NumberFormat SheetsNumberFormatCmd `cmd:"" name:"number-format" help:"Apply number format to a range"` diff --git a/internal/cmd/sheets_advanced_test.go b/internal/cmd/sheets_advanced_test.go new file mode 100644 index 00000000..806d6536 --- /dev/null +++ b/internal/cmd/sheets_advanced_test.go @@ -0,0 +1,267 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestSheetsConditionalAddBuildsRule(t *testing.T) { + ctx, flags, requests, rawRequests, cleanup := newSheetsAdvancedTestContext(t, sheetsAdvancedTestState{}) + defer cleanup() + + if err := runKong(t, &SheetsConditionalAddCmd{}, []string{ + "s1", "Sheet1!A2:C10", + "--type", "text-eq", + "--expr", "A", + "--format-json", `{"backgroundColor":{"red":1,"green":0.8}}`, + "--format-fields", "backgroundColor", + }, ctx, flags); err != nil { + t.Fatalf("conditional add: %v", err) + } + + if len(*requests) != 1 || len((*requests)[0].Requests) != 1 { + t.Fatalf("requests = %#v", *requests) + } + add := (*requests)[0].Requests[0].AddConditionalFormatRule + if add == nil || add.Rule == nil || add.Rule.BooleanRule == nil { + t.Fatalf("missing addConditionalFormatRule: %#v", (*requests)[0].Requests[0]) + } + if add.Index != 0 { + t.Fatalf("index = %d, want 0", add.Index) + } + condition := add.Rule.BooleanRule.Condition + if condition.Type != "TEXT_EQ" || len(condition.Values) != 1 || condition.Values[0].UserEnteredValue != "A" { + t.Fatalf("condition = %#v", condition) + } + if got := add.Rule.Ranges[0]; got.SheetId != 0 || got.StartRowIndex != 1 || got.EndRowIndex != 10 || got.StartColumnIndex != 0 || got.EndColumnIndex != 3 { + t.Fatalf("range = %#v", got) + } + if add.Rule.BooleanRule.Format == nil || add.Rule.BooleanRule.Format.BackgroundColor == nil { + t.Fatalf("missing format: %#v", add.Rule.BooleanRule.Format) + } + if !strings.Contains((*rawRequests)[0], `"sheetId":0`) { + t.Fatalf("request did not force-send zero sheetId: %s", (*rawRequests)[0]) + } + if !strings.Contains((*rawRequests)[0], `"backgroundColor"`) { + t.Fatalf("request missing backgroundColor: %s", (*rawRequests)[0]) + } +} + +func TestSheetsConditionalClearAllDeletesReverseAndRequiresForce(t *testing.T) { + ctx, flags, requests, _, cleanup := newSheetsAdvancedTestContext(t, sheetsAdvancedTestState{ + ConditionalRules: 2, + }) + defer cleanup() + + err := runKong(t, &SheetsConditionalClearCmd{}, []string{"s1", "--sheet", "Sheet1", "--all"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "without --force") { + t.Fatalf("expected force error, got %v", err) + } + if len(*requests) != 0 { + t.Fatalf("clear ran without force: %#v", *requests) + } + + forceFlags := *flags + forceFlags.Force = true + if err := runKong(t, &SheetsConditionalClearCmd{}, []string{"s1", "--sheet", "Sheet1", "--all"}, ctx, &forceFlags); err != nil { + t.Fatalf("conditional clear: %v", err) + } + if len(*requests) != 1 || len((*requests)[0].Requests) != 2 { + t.Fatalf("requests = %#v", *requests) + } + first := (*requests)[0].Requests[0].DeleteConditionalFormatRule + second := (*requests)[0].Requests[1].DeleteConditionalFormatRule + if first == nil || second == nil { + t.Fatalf("missing deleteConditionalFormatRule: %#v", (*requests)[0].Requests) + } + if first.Index != 1 || second.Index != 0 { + t.Fatalf("delete indexes = %d,%d; want 1,0", first.Index, second.Index) + } +} + +func TestSheetsBandingSetListAndClear(t *testing.T) { + ctx, flags, requests, _, cleanup := newSheetsAdvancedTestContext(t, sheetsAdvancedTestState{ + BandedRangeID: 777, + }) + defer cleanup() + + out := captureStdout(t, func() { + if err := runKong(t, &SheetsBandingSetCmd{}, []string{"s1", "Sheet1!A1:C5"}, ctx, flags); err != nil { + t.Fatalf("banding set: %v", err) + } + }) + if !strings.Contains(out, `"bandedRangeId": 777`) { + t.Fatalf("missing banded range id output: %s", out) + } + if len(*requests) != 1 || (*requests)[0].Requests[0].AddBanding == nil { + t.Fatalf("missing addBanding request: %#v", *requests) + } + add := (*requests)[0].Requests[0].AddBanding.BandedRange + if add.Range.SheetId != 0 || add.Range.StartRowIndex != 0 || add.Range.EndRowIndex != 5 || add.Range.EndColumnIndex != 3 { + t.Fatalf("add range = %#v", add.Range) + } + if add.RowProperties == nil || add.RowProperties.FirstBandColorStyle == nil || add.RowProperties.SecondBandColorStyle == nil { + t.Fatalf("missing default row banding properties: %#v", add.RowProperties) + } + + listOut := captureStdout(t, func() { + if err := runKong(t, &SheetsBandingListCmd{}, []string{"s1"}, ctx, flags); err != nil { + t.Fatalf("banding list: %v", err) + } + }) + if !strings.Contains(listOut, `"bandedRangeId": 777`) || !strings.Contains(listOut, `"a1": "Sheet1!A1:C5"`) { + t.Fatalf("missing banding list output: %s", listOut) + } + + err := runKong(t, &SheetsBandingClearCmd{}, []string{"s1", "--id", "777"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "without --force") { + t.Fatalf("expected force error, got %v", err) + } + + forceFlags := *flags + forceFlags.Force = true + if err := runKong(t, &SheetsBandingClearCmd{}, []string{"s1", "--id", "777"}, ctx, &forceFlags); err != nil { + t.Fatalf("banding clear: %v", err) + } + if len(*requests) != 2 || (*requests)[1].Requests[0].DeleteBanding == nil { + t.Fatalf("missing deleteBanding request: %#v", *requests) + } + if (*requests)[1].Requests[0].DeleteBanding.BandedRangeId != 777 { + t.Fatalf("delete banding id = %d", (*requests)[1].Requests[0].DeleteBanding.BandedRangeId) + } +} + +type sheetsAdvancedTestState struct { + ConditionalRules int + BandedRangeID int64 +} + +func newSheetsAdvancedTestContext(t *testing.T, state sheetsAdvancedTestState) (context.Context, *RootFlags, *[]sheets.BatchUpdateSpreadsheetRequest, *[]string, func()) { + t.Helper() + + origNew := newSheetsService + requests := []sheets.BatchUpdateSpreadsheetRequest{} + rawRequests := []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: + writeSheetsAdvancedMetadata(t, w, state) + case path == "/spreadsheets/s1:batchUpdate" && r.Method == http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read batchUpdate: %v", err) + } + rawRequests = append(rawRequests, string(body)) + var req sheets.BatchUpdateSpreadsheetRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + requests = append(requests, req) + writeSheetsAdvancedBatchReply(t, w, req, state) + default: + http.NotFound(w, r) + } + })) + + 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 } + + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + flags := &RootFlags{Account: "a@b.com"} + cleanup := func() { + newSheetsService = origNew + srv.Close() + } + return ctx, flags, &requests, &rawRequests, cleanup +} + +func writeSheetsAdvancedMetadata(t *testing.T, w http.ResponseWriter, state sheetsAdvancedTestState) { + t.Helper() + rules := make([]map[string]any, 0, state.ConditionalRules) + for i := 0; i < state.ConditionalRules; i++ { + rules = append(rules, map[string]any{ + "booleanRule": map[string]any{ + "condition": map[string]any{ + "type": "TEXT_EQ", + "values": []map[string]any{{"userEnteredValue": "A"}}, + }, + }, + "ranges": []map[string]any{{ + "sheetId": 0, + "startRowIndex": 1, + "endRowIndex": 5, + "startColumnIndex": 0, + "endColumnIndex": 3, + }}, + }) + } + banded := []map[string]any{} + if state.BandedRangeID != 0 { + banded = append(banded, map[string]any{ + "bandedRangeId": state.BandedRangeID, + "range": map[string]any{ + "sheetId": 0, + "startRowIndex": 0, + "endRowIndex": 5, + "startColumnIndex": 0, + "endColumnIndex": 3, + }, + }) + } + 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": 0, "title": "Sheet1"}, + "conditionalFormats": rules, + "bandedRanges": banded, + }}, + }) +} + +func writeSheetsAdvancedBatchReply(t *testing.T, w http.ResponseWriter, req sheets.BatchUpdateSpreadsheetRequest, state sheetsAdvancedTestState) { + t.Helper() + replies := make([]map[string]any, 0, len(req.Requests)) + for _, r := range req.Requests { + switch { + case r.AddBanding != nil: + replies = append(replies, map[string]any{ + "addBanding": map[string]any{ + "bandedRange": map[string]any{"bandedRangeId": state.BandedRangeID}, + }, + }) + default: + replies = append(replies, map[string]any{}) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "replies": replies, + }) +} diff --git a/internal/cmd/sheets_banding.go b/internal/cmd/sheets_banding.go new file mode 100644 index 00000000..b505f63b --- /dev/null +++ b/internal/cmd/sheets_banding.go @@ -0,0 +1,349 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsBandingCmd struct { + List SheetsBandingListCmd `cmd:"" default:"withargs" help:"List alternating color banded ranges"` + Set SheetsBandingSetCmd `cmd:"" name:"set" aliases:"add,create" help:"Apply alternating colors to a range"` + Clear SheetsBandingClearCmd `cmd:"" name:"clear" aliases:"delete,rm,remove" help:"Remove alternating color banding"` +} + +type SheetsBandingSetCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Range string `arg:"" name:"range" help:"A1 range with sheet name (e.g. Sheet1!A1:H20)"` + RowPropertiesJSON string `name:"row-properties-json" help:"Sheets API BandingProperties JSON for row colors"` + ColumnPropertiesJSON string `name:"column-properties-json" help:"Sheets API BandingProperties JSON for column colors"` +} + +func (c *SheetsBandingSetCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + rangeSpec := cleanRange(c.Range) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if strings.TrimSpace(rangeSpec) == "" { + return usage("empty range") + } + parsedRange, err := parseSheetRange(rangeSpec, "banding") + if err != nil { + return err + } + rowProps, colProps, err := bandingProperties(c.RowPropertiesJSON, c.ColumnPropertiesJSON) + if err != nil { + return err + } + + if dryErr := dryRunExit(ctx, flags, "sheets.banding.set", map[string]any{ + "spreadsheet_id": spreadsheetID, + "range": rangeSpec, + "row_properties": rowProps, + "column_properties": colProps, + }); dryErr != nil { + return dryErr + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID) + if err != nil { + return err + } + gridRange, err := gridRangeFromMap(parsedRange, sheetIDs, "banding") + if err != nil { + return err + } + + req := &sheets.BatchUpdateSpreadsheetRequest{Requests: []*sheets.Request{{ + AddBanding: &sheets.AddBandingRequest{BandedRange: &sheets.BandedRange{ + Range: gridRange, + RowProperties: rowProps, + ColumnProperties: colProps, + }}, + }}} + resp, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Context(ctx).Do() + if err != nil { + return err + } + var bandedRangeID int64 + if len(resp.Replies) > 0 && resp.Replies[0].AddBanding != nil && resp.Replies[0].AddBanding.BandedRange != nil { + bandedRangeID = resp.Replies[0].AddBanding.BandedRange.BandedRangeId + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "bandedRangeId": bandedRangeID, + "range": rangeSpec, + }) + } + u.Out().Printf("Applied banding %d to %s", bandedRangeID, rangeSpec) + return nil +} + +type SheetsBandingListCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Sheet string `name:"sheet" help:"Only list banding from this sheet"` +} + +func (c *SheetsBandingListCmd) 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)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(properties(sheetId,title),bandedRanges)"). + Context(ctx). + Do() + if err != nil { + return err + } + + items := bandingItems(resp, strings.TrimSpace(c.Sheet)) + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"bandedRanges": items}) + } + if len(items) == 0 { + u.Err().Println("No banded ranges") + return nil + } + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "BANDED_RANGE_ID\tSHEET\tRANGE") + for _, item := range items { + fmt.Fprintf(w, "%d\t%s\t%s\n", item.BandedRangeID, item.SheetTitle, item.A1) + } + return nil +} + +type SheetsBandingClearCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + BandedRangeID int64 `name:"id" help:"Banded range ID to remove"` + Sheet string `name:"sheet" help:"Sheet name for --all"` + All bool `name:"all" help:"Remove all banding from the sheet"` +} + +func (c *SheetsBandingClearCmd) Run(ctx context.Context, flags *RootFlags) error { + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + sheetName := strings.TrimSpace(c.Sheet) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if c.BandedRangeID > 0 && c.All { + return usage("use either --id or --all, not both") + } + if c.BandedRangeID <= 0 && !c.All { + return usage("provide --id or --all") + } + if c.All && sheetName == "" { + return usage("--sheet is required with --all") + } + + requests := []*sheets.Request{} + var removed int + if c.BandedRangeID > 0 { + requests = append(requests, bandingDeleteRequest(c.BandedRangeID)) + removed = 1 + } else { + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(properties(title),bandedRanges(bandedRangeId))"). + Context(ctx). + Do() + if err != nil { + return err + } + ids, found := bandingIDsForSheet(resp, sheetName) + if !found { + return usagef("unknown sheet %q", sheetName) + } + for _, id := range ids { + requests = append(requests, bandingDeleteRequest(id)) + } + removed = len(requests) + } + + if len(requests) == 0 { + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"removed": 0}) + } + ui.FromContext(ctx).Out().Println("No banded ranges to remove") + return nil + } + + if err := dryRunAndConfirmDestructive(ctx, flags, "sheets.banding.clear", map[string]any{ + "spreadsheet_id": spreadsheetID, + "banded_range_id": c.BandedRangeID, + "sheet": sheetName, + "all": c.All, + "removed": removed, + }, "remove banding"); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + if err := applySheetsBatchUpdate(ctx, svc, spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{Requests: requests}); err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "removed": removed, + }) + } + ui.FromContext(ctx).Out().Printf("Removed %d banded ranges", removed) + return nil +} + +type bandingItem struct { + BandedRangeID int64 `json:"bandedRangeId"` + SheetID int64 `json:"sheetId"` + SheetTitle string `json:"sheetTitle"` + A1 string `json:"a1,omitempty"` + Range *sheets.GridRange `json:"range,omitempty"` + RowProperties *sheets.BandingProperties `json:"rowProperties,omitempty"` + ColumnProperties *sheets.BandingProperties `json:"columnProperties,omitempty"` +} + +func bandingProperties(rowJSON, columnJSON string) (*sheets.BandingProperties, *sheets.BandingProperties, error) { + rowProps, err := parseBandingProperties(rowJSON, defaultRowBandingProperties()) + if err != nil { + return nil, nil, fmt.Errorf("invalid --row-properties-json: %w", err) + } + colProps, err := parseBandingProperties(columnJSON, nil) + if err != nil { + return nil, nil, fmt.Errorf("invalid --column-properties-json: %w", err) + } + if rowProps == nil && colProps == nil { + return nil, nil, usage("provide row or column banding properties") + } + return rowProps, colProps, nil +} + +func parseBandingProperties(raw string, fallback *sheets.BandingProperties) (*sheets.BandingProperties, error) { + if strings.TrimSpace(raw) == "" { + return fallback, nil + } + b, err := resolveInlineOrFileBytes(raw) + if err != nil { + return nil, err + } + var props sheets.BandingProperties + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&props); err != nil { + return nil, err + } + var extra any + if err := dec.Decode(&extra); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("multiple JSON values") + } + return nil, err + } + return &props, nil +} + +func defaultRowBandingProperties() *sheets.BandingProperties { + return &sheets.BandingProperties{ + HeaderColorStyle: &sheets.ColorStyle{RgbColor: &sheets.Color{Red: 0.88, Green: 0.93, Blue: 1}}, + FirstBandColorStyle: &sheets.ColorStyle{RgbColor: &sheets.Color{Red: 1, Green: 1, Blue: 1}}, + SecondBandColorStyle: &sheets.ColorStyle{RgbColor: &sheets.Color{Red: 0.96, Green: 0.98, Blue: 1}}, + } +} + +func bandingItems(resp *sheets.Spreadsheet, onlySheet string) []bandingItem { + items := make([]bandingItem, 0) + if resp == nil { + return items + } + for _, sheet := range resp.Sheets { + if sheet == nil || sheet.Properties == nil { + continue + } + sheetTitle := sheet.Properties.Title + if onlySheet != "" && sheetTitle != onlySheet { + continue + } + for _, br := range sheet.BandedRanges { + if br == nil { + continue + } + items = append(items, bandingItem{ + BandedRangeID: br.BandedRangeId, + SheetID: sheet.Properties.SheetId, + SheetTitle: sheetTitle, + A1: gridRangeToA1(sheetTitle, br.Range), + Range: br.Range, + RowProperties: br.RowProperties, + ColumnProperties: br.ColumnProperties, + }) + } + } + return items +} + +func bandingIDsForSheet(resp *sheets.Spreadsheet, sheetName string) ([]int64, bool) { + if resp == nil { + return nil, false + } + for _, sheet := range resp.Sheets { + if sheet == nil || sheet.Properties == nil || sheet.Properties.Title != sheetName { + continue + } + ids := make([]int64, 0, len(sheet.BandedRanges)) + for _, br := range sheet.BandedRanges { + if br != nil { + ids = append(ids, br.BandedRangeId) + } + } + return ids, true + } + return nil, false +} + +func bandingDeleteRequest(id int64) *sheets.Request { + return &sheets.Request{DeleteBanding: &sheets.DeleteBandingRequest{BandedRangeId: id}} +} diff --git a/internal/cmd/sheets_conditional.go b/internal/cmd/sheets_conditional.go new file mode 100644 index 00000000..d50419d7 --- /dev/null +++ b/internal/cmd/sheets_conditional.go @@ -0,0 +1,398 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type SheetsConditionalCmd struct { + List SheetsConditionalListCmd `cmd:"" default:"withargs" help:"List conditional formatting rules"` + Add SheetsConditionalAddCmd `cmd:"" name:"add" aliases:"create,new" help:"Add a conditional formatting rule"` + Clear SheetsConditionalClearCmd `cmd:"" name:"clear" aliases:"delete,rm,remove" help:"Remove conditional formatting rules"` +} + +type SheetsConditionalAddCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Range string `arg:"" name:"range" help:"A1 range with sheet name (e.g. Sheet1!A2:J)"` + Type string `name:"type" required:"" help:"Rule type: text-eq|text-contains|text-starts-with|text-ends-with|number-eq|number-gt|number-gte|number-lt|number-lte|blank|not-blank|custom-formula"` + Expr string `name:"expr" help:"Expression value or custom formula (omit for blank/not-blank)"` + FormatJSON string `name:"format-json" required:"" help:"CellFormat JSON (inline or @file)"` + FormatFields string `name:"format-fields" help:"Format field mask for force-sending zero/false fields (e.g. backgroundColor,textFormat.bold)"` + Index int64 `name:"index" help:"Insert rule at this priority index" default:"0"` +} + +func (c *SheetsConditionalAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + rangeSpec := cleanRange(c.Range) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if strings.TrimSpace(rangeSpec) == "" { + return usage("empty range") + } + if c.Index < 0 { + return usage("--index must be zero or greater") + } + + parsedRange, err := parseSheetRange(rangeSpec, "conditional-format") + if err != nil { + return err + } + format, formatFields, err := parseConditionalFormat(c.FormatJSON, c.FormatFields) + if err != nil { + return err + } + conditionType, values, err := conditionalCondition(strings.TrimSpace(c.Type), strings.TrimSpace(c.Expr)) + if err != nil { + return err + } + + if dryErr := dryRunExit(ctx, flags, "sheets.conditional-format.add", map[string]any{ + "spreadsheet_id": spreadsheetID, + "range": rangeSpec, + "type": conditionType, + "values": values, + "format_fields": formatFields, + "index": c.Index, + }); dryErr != nil { + return dryErr + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID) + if err != nil { + return err + } + gridRange, err := gridRangeFromMap(parsedRange, sheetIDs, "conditional-format") + if err != nil { + return err + } + + req := &sheets.BatchUpdateSpreadsheetRequest{Requests: []*sheets.Request{{ + AddConditionalFormatRule: &sheets.AddConditionalFormatRuleRequest{ + Rule: &sheets.ConditionalFormatRule{ + BooleanRule: &sheets.BooleanRule{ + Condition: &sheets.BooleanCondition{ + Type: conditionType, + Values: values, + }, + Format: format, + }, + Ranges: []*sheets.GridRange{gridRange}, + }, + Index: c.Index, + }, + }}} + + if err := applySheetsBatchUpdate(ctx, svc, spreadsheetID, req); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "range": rangeSpec, + "type": conditionType, + "index": c.Index, + }) + } + u.Out().Printf("Added conditional format rule to %s", rangeSpec) + return nil +} + +type SheetsConditionalListCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Sheet string `name:"sheet" help:"Only list rules from this sheet"` +} + +func (c *SheetsConditionalListCmd) 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)) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(properties(sheetId,title),conditionalFormats)"). + Context(ctx). + Do() + if err != nil { + return err + } + + items := conditionalRuleItems(resp, strings.TrimSpace(c.Sheet)) + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"rules": items}) + } + if len(items) == 0 { + u.Err().Println("No conditional format rules") + return nil + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "SHEET\tINDEX\tTYPE\tRANGES") + for _, item := range items { + fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", item.SheetTitle, item.Index, item.Type, strings.Join(item.Ranges, ",")) + } + return nil +} + +type SheetsConditionalClearCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Sheet string `name:"sheet" required:"" help:"Sheet name"` + Index string `name:"index" help:"Rule index to remove"` + All bool `name:"all" help:"Remove all conditional formatting rules from the sheet"` +} + +func (c *SheetsConditionalClearCmd) Run(ctx context.Context, flags *RootFlags) error { + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + sheetName := strings.TrimSpace(c.Sheet) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if sheetName == "" { + return usage("empty --sheet") + } + if !c.All && strings.TrimSpace(c.Index) == "" { + return usage("provide --index or --all") + } + if c.All && strings.TrimSpace(c.Index) != "" { + return usage("use either --index or --all, not both") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + resp, err := svc.Spreadsheets.Get(spreadsheetID). + Fields("sheets(properties(sheetId,title),conditionalFormats)"). + Context(ctx). + Do() + if err != nil { + return err + } + sheetID, count, err := conditionalSheetRuleCount(resp, sheetName) + if err != nil { + return err + } + + requests, err := conditionalDeleteRequests(sheetID, count, strings.TrimSpace(c.Index), c.All) + if err != nil { + return err + } + if len(requests) == 0 { + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"removed": 0}) + } + ui.FromContext(ctx).Out().Println("No conditional format rules to remove") + return nil + } + + if err := dryRunAndConfirmDestructive(ctx, flags, "sheets.conditional-format.clear", map[string]any{ + "spreadsheet_id": spreadsheetID, + "sheet": sheetName, + "index": strings.TrimSpace(c.Index), + "all": c.All, + "removed": len(requests), + }, "remove conditional format rules from "+sheetName); err != nil { + return err + } + + if err := applySheetsBatchUpdate(ctx, svc, spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{Requests: requests}); err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "spreadsheetId": spreadsheetID, + "sheet": sheetName, + "removed": len(requests), + }) + } + ui.FromContext(ctx).Out().Printf("Removed %d conditional format rules from %s", len(requests), sheetName) + return nil +} + +type conditionalRuleItem struct { + SheetID int64 `json:"sheetId"` + SheetTitle string `json:"sheetTitle"` + Index int `json:"index"` + Type string `json:"type,omitempty"` + Values []string `json:"values,omitempty"` + Ranges []string `json:"ranges,omitempty"` + Rule any `json:"rule,omitempty"` +} + +func parseConditionalFormat(formatJSON, formatMask string) (*sheets.CellFormat, string, error) { + b, err := resolveInlineOrFileBytes(formatJSON) + if err != nil { + return nil, "", fmt.Errorf("read --format-json: %w", err) + } + var format sheets.CellFormat + if err := decodeCellFormatJSON(b, &format); err != nil { + return nil, "", fmt.Errorf("invalid --format-json: %w", err) + } + formatFields := strings.TrimSpace(formatMask) + if formatFields != "" { + if hasBoardersTypo(formatFields) { + return nil, "", fmt.Errorf(`invalid --format-fields: found "boarders"; use "borders"`) + } + normalized, formatPaths := normalizeFormatMask(formatFields) + formatFields = strings.TrimPrefix(normalized, sheetsUserEnteredFormatPrefix+".") + formatFields = strings.ReplaceAll(formatFields, ","+sheetsUserEnteredFormatPrefix+".", ",") + if err := applyForceSendFields(&format, formatPaths); err != nil { + return nil, "", err + } + } + return &format, formatFields, nil +} + +func conditionalCondition(kind, expr string) (string, []*sheets.ConditionValue, error) { + conditionType, valueCount, err := conditionalConditionType(kind) + if err != nil { + return "", nil, err + } + if valueCount == 0 { + if expr != "" { + return "", nil, usagef("--expr is not used with --type %s", kind) + } + return conditionType, nil, nil + } + if expr == "" { + return "", nil, usage("--expr is required for this conditional format type") + } + return conditionType, []*sheets.ConditionValue{{UserEnteredValue: expr}}, nil +} + +func conditionalConditionType(kind string) (string, int, error) { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "text-eq": + return "TEXT_EQ", 1, nil + case "text-contains": + return "TEXT_CONTAINS", 1, nil + case "text-starts-with": + return "TEXT_STARTS_WITH", 1, nil + case "text-ends-with": + return "TEXT_ENDS_WITH", 1, nil + case "number-eq": + return "NUMBER_EQ", 1, nil + case "number-gt": + return "NUMBER_GREATER", 1, nil + case "number-gte": + return "NUMBER_GREATER_THAN_EQ", 1, nil + case "number-lt": + return "NUMBER_LESS", 1, nil + case "number-lte": + return "NUMBER_LESS_THAN_EQ", 1, nil + case "blank": + return "BLANK", 0, nil + case "not-blank": + return "NOT_BLANK", 0, nil + case "custom-formula": + return "CUSTOM_FORMULA", 1, nil + default: + return "", 0, usagef("unsupported --type %q", kind) + } +} + +func conditionalRuleItems(resp *sheets.Spreadsheet, onlySheet string) []conditionalRuleItem { + items := make([]conditionalRuleItem, 0) + if resp == nil { + return items + } + for _, sheet := range resp.Sheets { + if sheet == nil || sheet.Properties == nil { + continue + } + sheetTitle := sheet.Properties.Title + if onlySheet != "" && sheetTitle != onlySheet { + continue + } + for idx, rule := range sheet.ConditionalFormats { + item := conditionalRuleItem{ + SheetID: sheet.Properties.SheetId, + SheetTitle: sheetTitle, + Index: idx, + Rule: rule, + } + if rule != nil { + for _, gr := range rule.Ranges { + item.Ranges = append(item.Ranges, gridRangeToA1(sheetTitle, gr)) + } + if rule.BooleanRule != nil && rule.BooleanRule.Condition != nil { + item.Type = rule.BooleanRule.Condition.Type + for _, value := range rule.BooleanRule.Condition.Values { + if value != nil { + item.Values = append(item.Values, value.UserEnteredValue) + } + } + } + } + items = append(items, item) + } + } + return items +} + +func conditionalSheetRuleCount(resp *sheets.Spreadsheet, sheetName string) (int64, int, error) { + if resp == nil { + return 0, 0, fmt.Errorf("empty spreadsheet metadata") + } + for _, sheet := range resp.Sheets { + if sheet == nil || sheet.Properties == nil || sheet.Properties.Title != sheetName { + continue + } + return sheet.Properties.SheetId, len(sheet.ConditionalFormats), nil + } + return 0, 0, usagef("unknown sheet %q", sheetName) +} + +func conditionalDeleteRequests(sheetID int64, count int, indexRaw string, all bool) ([]*sheets.Request, error) { + if all { + requests := make([]*sheets.Request, 0, count) + for i := count - 1; i >= 0; i-- { + requests = append(requests, conditionalDeleteRequest(sheetID, int64(i))) + } + return requests, nil + } + idx, err := strconv.Atoi(indexRaw) + if err != nil || idx < 0 { + return nil, usage("invalid --index") + } + if idx >= count { + return nil, usagef("--index %d out of range; sheet has %d rules", idx, count) + } + return []*sheets.Request{conditionalDeleteRequest(sheetID, int64(idx))}, nil +} + +func conditionalDeleteRequest(sheetID, index int64) *sheets.Request { + return &sheets.Request{DeleteConditionalFormatRule: &sheets.DeleteConditionalFormatRuleRequest{SheetId: sheetID, Index: index}} +}