feat(sheets): add conditional formatting and banding
Adds Google Sheets conditional formatting and alternating color banding commands, with force-guarded clears, docs, regression tests, and live Google smoke verification.\n\nCo-authored-by: gobang <50824182+codBang@users.noreply.github.com>
This commit is contained in:
parent
aa7c0a2f90
commit
b836495775
@ -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.
|
||||
|
||||
@ -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 <spreadsheetId> 'Sheet1!A:C' --auto
|
||||
gog sheets resize-rows <spreadsheetId> 'Sheet1!1:10' --height 36
|
||||
gog sheets read-format <spreadsheetId> 'Sheet1!A1:B2'
|
||||
gog sheets read-format <spreadsheetId> 'Sheet1!A1:B2' --effective
|
||||
gog sheets conditional-format add <spreadsheetId> '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 <spreadsheetId>
|
||||
gog sheets conditional-format clear <spreadsheetId> --sheet Sheet1 --all --force
|
||||
gog sheets banding set <spreadsheetId> 'Sheet1!A1:C20'
|
||||
gog sheets banding list <spreadsheetId>
|
||||
gog sheets banding clear <spreadsheetId> --sheet Sheet1 --all --force
|
||||
# See docs/sheets-formatting.md for conditional format and banding details.
|
||||
|
||||
# Named ranges
|
||||
gog sheets named-ranges <spreadsheetId>
|
||||
|
||||
@ -382,6 +382,10 @@ Generated from `gog schema --json`.
|
||||
- [`gog sheets (sheet) <command> [flags]`](commands/gog-sheets.md) - Google Sheets
|
||||
- [`gog sheets (sheet) add-tab (add-sheet) <spreadsheetId> <tabName> [flags]`](commands/gog-sheets-add-tab.md) - Add a new tab/sheet to a spreadsheet
|
||||
- [`gog sheets (sheet) append (add) <spreadsheetId> <range> [<values> ...] [flags]`](commands/gog-sheets-append.md) - Append values to a range
|
||||
- [`gog sheets (sheet) banding (banded-ranges) <command>`](commands/gog-sheets-banding.md) - Manage alternating color banding
|
||||
- [`gog sheets (sheet) banding (banded-ranges) clear (delete,rm,remove) <spreadsheetId> [flags]`](commands/gog-sheets-banding-clear.md) - Remove alternating color banding
|
||||
- [`gog sheets (sheet) banding (banded-ranges) list <spreadsheetId> [flags]`](commands/gog-sheets-banding-list.md) - List alternating color banded ranges
|
||||
- [`gog sheets (sheet) banding (banded-ranges) set (add,create) <spreadsheetId> <range> [flags]`](commands/gog-sheets-banding-set.md) - Apply alternating colors to a range
|
||||
- [`gog sheets (sheet) chart (charts) <command>`](commands/gog-sheets-chart.md) - Manage spreadsheet charts
|
||||
- [`gog sheets (sheet) chart (charts) create (add,new) --spec-json=STRING <spreadsheetId> [flags]`](commands/gog-sheets-chart-create.md) - Create a chart from a JSON spec
|
||||
- [`gog sheets (sheet) chart (charts) delete (rm,remove,del) <spreadsheetId> <chartId>`](commands/gog-sheets-chart-delete.md) - Delete a chart
|
||||
@ -389,6 +393,10 @@ Generated from `gog schema --json`.
|
||||
- [`gog sheets (sheet) chart (charts) list <spreadsheetId>`](commands/gog-sheets-chart-list.md) - List charts in a spreadsheet
|
||||
- [`gog sheets (sheet) chart (charts) update (edit,set) --spec-json=STRING <spreadsheetId> <chartId>`](commands/gog-sheets-chart-update.md) - Update a chart spec
|
||||
- [`gog sheets (sheet) clear <spreadsheetId> <range>`](commands/gog-sheets-clear.md) - Clear values in a range
|
||||
- [`gog sheets (sheet) conditional-format (cf,conditional-formats) <command>`](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 <spreadsheetId> <range> [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 <spreadsheetId> [flags]`](commands/gog-sheets-conditional-format-clear.md) - Remove conditional formatting rules
|
||||
- [`gog sheets (sheet) conditional-format (cf,conditional-formats) list <spreadsheetId> [flags]`](commands/gog-sheets-conditional-format-list.md) - List conditional formatting rules
|
||||
- [`gog sheets (sheet) copy (cp,duplicate) <spreadsheetId> <title> [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)
|
||||
|
||||
@ -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)
|
||||
|
||||
45
docs/commands/gog-sheets-banding-clear.md
Normal file
45
docs/commands/gog-sheets-banding-clear.md
Normal file
@ -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)
|
||||
43
docs/commands/gog-sheets-banding-list.md
Normal file
43
docs/commands/gog-sheets-banding-list.md
Normal file
@ -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)
|
||||
44
docs/commands/gog-sheets-banding-set.md
Normal file
44
docs/commands/gog-sheets-banding-set.md
Normal file
@ -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)
|
||||
48
docs/commands/gog-sheets-banding.md
Normal file
48
docs/commands/gog-sheets-banding.md
Normal file
@ -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)
|
||||
47
docs/commands/gog-sheets-conditional-format-add.md
Normal file
47
docs/commands/gog-sheets-conditional-format-add.md
Normal file
@ -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)
|
||||
45
docs/commands/gog-sheets-conditional-format-clear.md
Normal file
45
docs/commands/gog-sheets-conditional-format-clear.md
Normal file
@ -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)
|
||||
43
docs/commands/gog-sheets-conditional-format-list.md
Normal file
43
docs/commands/gog-sheets-conditional-format-list.md
Normal file
@ -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)
|
||||
48
docs/commands/gog-sheets-conditional-format.md
Normal file
48
docs/commands/gog-sheets-conditional-format.md
Normal file
@ -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)
|
||||
@ -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)
|
||||
|
||||
88
docs/sheets-formatting.md
Normal file
88
docs/sheets-formatting.md
Normal file
@ -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)
|
||||
@ -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"`
|
||||
|
||||
267
internal/cmd/sheets_advanced_test.go
Normal file
267
internal/cmd/sheets_advanced_test.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
349
internal/cmd/sheets_banding.go
Normal file
349
internal/cmd/sheets_banding.go
Normal file
@ -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}}
|
||||
}
|
||||
398
internal/cmd/sheets_conditional.go
Normal file
398
internal/cmd/sheets_conditional.go
Normal file
@ -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}}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user