diff --git a/.agents/skills/gog-searchconsole/SKILL.md b/.agents/skills/gog-searchconsole/SKILL.md index 1d2542af3..d0cc78b7a 100644 --- a/.agents/skills/gog-searchconsole/SKILL.md +++ b/.agents/skills/gog-searchconsole/SKILL.md @@ -28,6 +28,7 @@ gog --readonly --account user@example.com searchconsole --help | Command | Purpose | | --- | --- | +| `inspect` | Inspect URL index status (URL Inspection API) | | `query` | Run a Search Analytics query | | `searchanalytics` | Search Analytics queries | | `sitemaps` | List/get/submit/delete sitemaps | diff --git a/.agents/skills/gog-sheets/SKILL.md b/.agents/skills/gog-sheets/SKILL.md index a1a86b29e..34f6075dc 100644 --- a/.agents/skills/gog-sheets/SKILL.md +++ b/.agents/skills/gog-sheets/SKILL.md @@ -41,6 +41,7 @@ gog --readonly --account user@example.com sheets get SHEET_ID 'Sheet1!A1:D20' -- | `datasource` | Manage Connected Sheets data sources and extracts | | `delete-dimension` | Delete rows or columns while preserving intersecting tables | | `delete-tab` | Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) | +| `duplicate-tab` | Duplicate a tab within a spreadsheet | | `export` | Export a Google Sheet (pdf\|xlsx\|csv) via Drive | | `filter` | Manage basic filters | | `find-replace` | Find and replace text across a spreadsheet | diff --git a/.agents/skills/gog-slides/SKILL.md b/.agents/skills/gog-slides/SKILL.md index 5c095d761..291381886 100644 --- a/.agents/skills/gog-slides/SKILL.md +++ b/.agents/skills/gog-slides/SKILL.md @@ -46,6 +46,7 @@ gog --readonly --account user@example.com slides --help | `locate` | Locate text in shapes and table cells with object IDs and UTF-16 ranges | | `move-slide` | Move a slide to a zero-based insertion index | | `new-slide` | Create a native themed slide | +| `paragraph-style` | Set paragraph alignment, spacing, indentation, or direction | | `raw` | Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) | | `read-slide` | Read slide content: speaker notes, text elements, and images | | `replace-slide` | Replace an existing slide image from a local file or public URL | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aecf09ea..5318c3945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: ci on: push: + branches: + - main pull_request: permissions: @@ -34,8 +36,23 @@ jobs: run: make lint - name: Deadcode run: make deadcode - - name: Docs check - run: make docs-check + - name: Docs, skills, and Docker version checks + run: make docs-check agent-skills-check docker-version-check + + minimum-go: + runs-on: ubuntu-latest + env: + GOTOOLCHAIN: local + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: "1.26.8" + cache: true + - name: Test minimum supported Go series + run: go test ./... + - name: Build + run: go build ./cmd/gog worker: runs-on: ubuntu-latest @@ -47,11 +64,13 @@ jobs: - name: Enable Corepack (pnpm) run: | corepack enable - corepack prepare pnpm@11.25.0 --activate + corepack prepare pnpm@11.26.0 --activate - name: Install dependencies run: pnpm -C internal/tracking/worker install --frozen-lockfile - name: Lint run: pnpm -C internal/tracking/worker lint + - name: Typecheck + run: pnpm -C internal/tracking/worker typecheck - name: Build run: pnpm -C internal/tracking/worker build - name: Test diff --git a/CHANGELOG.md b/CHANGELOG.md index aec54f654..4bb7ace92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # Changelog -## 0.39.2 - Unreleased +## 0.40.1 - Unreleased + +- Search Console: add `searchconsole inspect` for per-URL index status via the URL Inspection API (coverage state, indexing/page-fetch/robots.txt state, canonical, sitemaps, last crawl time), using the existing `webmasters` OAuth scope. (#1094) — thanks @laihenyi. +- Search Console: preserve permission-denied exit codes when adding API setup or scope guidance. (#1094) +- Dependencies and CI: refresh Google protocol modules and tracking-worker tooling within release-age limits; test the Go 1.26 minimum, check worker types and generated skills, and avoid duplicate pull-request test runs. (#1131) + +## 0.40.0 - 2026-09-11 + +**Highlights:** Preserve Slides styling, format paragraphs, and reuse cached Discovery documents; duplicate or inspect individual spreadsheet tabs and sort Drive files. + +- Slides: preserve leading text styling during `insert-text --replace`, including template inheritance, with revision protection and correct empty-target and UTF-16 handling. (#1111, #1122) — thanks @sebsnyk. +- Slides: add `paragraph-style` for shape and table-cell alignment, spacing, indentation, and direction, with explicit field masks and zero-value support. (#1098, #1123) — thanks @sebsnyk. +- API: cache Discovery documents for 24 hours with bounded disk usage, isolated endpoint/version keys, and explicit `--no-cache` bypass; keep API responses and authorization checks uncached. (#1106, #1121) — thanks @gurgeous. +- Sheets: duplicate a tab within its spreadsheet, with an explicit destination name and optional insertion index. (#1102, #1116) — thanks @gurgeous. +- Sheets: filter raw API reads by exact tab title with `--sheet`, including A1-like names and apostrophes, while retaining spreadsheet metadata and the grid-data opt-in. (#1104, #1115) — thanks @gurgeous. +- Drive: add opt-in `ls --sort` and `--order` controls while preserving the existing modification-time default and native pagination. (#1105, #1117) — thanks @gurgeous. +- Slides: delete several page elements in one guarded API batch, retain single-element output compatibility, and document replacing WordArt elements. (#1109) — thanks @sebsnyk. +- Sheets: preserve zero-valued dimension indices so inserting before the first row or column succeeds. (#1107, #1114) — thanks @gurgeous. +- Docs: replace the reserved `default` alias command with the account manager's **Set default** action and explain account-selection precedence. (#1092, #1093) — thanks @gianpaj and @goutamadwant. +- Dependencies: refresh Go networking, authentication and cryptography modules and tracking-worker tooling, including the patched Sharp dependency, while retaining Go 1.26 compatibility and the worker's 24-hour release-age policy. +- Tooling: refresh goimports and deadcode to x/tools v0.50.0 while retaining the Go 1.26 minimum. (#1120) ## 0.39.1 - 2026-09-05 diff --git a/Makefile b/Makefile index 05ab8c5dc..61c2cf80a 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ GOIMPORTS := $(TOOLS_DIR)/goimports GOLANGCI_LINT := $(TOOLS_DIR)/golangci-lint DEADCODE := $(TOOLS_DIR)/deadcode TOOLS_STAMP := $(TOOLS_DIR)/.versions -TOOLS_VERSION := gofumpt=v0.11.0;goimports=v0.49.0;golangci-lint=v2.13.2;deadcode=v0.49.0 +TOOLS_VERSION := gofumpt=v0.11.0;goimports=v0.50.0;golangci-lint=v2.13.2;deadcode=v0.50.0 # Allow passing CLI args as extra "targets": # make gogcli -- --help @@ -91,9 +91,9 @@ tools: else \ set -e; \ GOBIN=$(TOOLS_DIR) go install mvdan.cc/gofumpt@v0.11.0; \ - GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/goimports@v0.49.0; \ + GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/goimports@v0.50.0; \ GOBIN=$(TOOLS_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2; \ - GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/deadcode@v0.49.0; \ + GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/deadcode@v0.50.0; \ printf '%s\n' "$(TOOLS_VERSION)" > "$(TOOLS_STAMP)"; \ fi @@ -164,5 +164,6 @@ ci: pnpm-gate docker-version-check fmt-check lint deadcode test docs-check agent worker-ci: @pnpm -C internal/tracking/worker lint + @pnpm -C internal/tracking/worker typecheck @pnpm -C internal/tracking/worker build @pnpm -C internal/tracking/worker test diff --git a/README.md b/README.md index f1e0250d5..20f567f76 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ binary. | meet | yes | Meet REST API | `https://www.googleapis.com/auth/meetings.space.created`
`https://www.googleapis.com/auth/meetings.space.readonly`
`https://www.googleapis.com/auth/meetings.space.settings` | | | appscript | yes | Apps Script API | `https://www.googleapis.com/auth/script.projects`
`https://www.googleapis.com/auth/script.deployments`
`https://www.googleapis.com/auth/script.processes` | | | analytics | yes | Analytics Admin API, Analytics Data API | `https://www.googleapis.com/auth/analytics.readonly` | GA4 account summaries + reporting | -| searchconsole | yes | Search Console API | `https://www.googleapis.com/auth/webmasters` | Search Analytics + sitemap management | +| searchconsole | yes | Search Console API | `https://www.googleapis.com/auth/webmasters` | Search Analytics + sitemap management + URL Inspection | | adsense | no | AdSense Management API | `https://www.googleapis.com/auth/adsense.readonly` | Consumer OAuth; explicit opt-in with --services adsense; read-only | | ads | yes | Google Ads API | `https://www.googleapis.com/auth/adwords` | OAuth scope only | | groups | no | Cloud Identity API | `https://www.googleapis.com/auth/cloud-identity.groups.readonly` | Workspace only | diff --git a/docs/automation.md b/docs/automation.md index 5c52a4c79..265d31960 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -187,6 +187,20 @@ fi New classifications may be added. Keep a generic non-zero fallback. +## Discovery document cache + +`gog api call` and `gog api describe` share a 24-hour on-disk cache of +Discovery documents under the configured cache directory's `discovery` +subdirectory. It keeps at most 32 documents and 64 MiB, evicting the oldest +documents; individual documents are limited to 16 MiB. API versions, endpoint +overrides and service-hosted fallback behavior use separate cache keys. + +Pass `--no-cache` to either command to fetch without reading or writing this +cache. Missing, expired or corrupt entries are fetched again. Cache failures +do not prevent network access, and failed fetches are not cached. `api list` +and actual API responses remain uncached; authorization and command-policy +checks still run on every call. + ## MCP discovery MCP uses its standard `tools/list` request for client-side tool discovery. To diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 0540cd1eb..22a5592a3 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -64,7 +64,7 @@ Generated from `gog schema --json`. - [`gog analytics (ga) report [flags]`](commands/gog-analytics-report.md) - Run a GA4 report (Analytics Data API) - [`gog api [flags]`](commands/gog-api.md) - Google Discovery APIs and generic method calls - [`gog api call [flags]`](commands/gog-api-call.md) - Call a Discovery-described API method - - [`gog api describe []`](commands/gog-api-describe.md) - Describe a Discovery API or method + - [`gog api describe [] [flags]`](commands/gog-api-describe.md) - Describe a Discovery API or method - [`gog api list [flags]`](commands/gog-api-list.md) - List Google Discovery APIs - [`gog appscript (script,apps-script) [flags]`](commands/gog-appscript.md) - Google Apps Script - [`gog appscript (script,apps-script) content (cat) `](commands/gog-appscript-content.md) - Get Apps Script project content @@ -574,6 +574,7 @@ Generated from `gog schema --json`. - [`gog schema (help-json,helpjson) [ ...] [flags]`](commands/gog-schema.md) - Machine-readable command/flag schema - [`gog search (find) ... [flags]`](commands/gog-search.md) - Search Drive files (alias for 'drive search') - [`gog searchconsole (gsc,search-console,webmasters) [flags]`](commands/gog-searchconsole.md) - Google Search Console + - [`gog searchconsole (gsc,search-console,webmasters) inspect [flags]`](commands/gog-searchconsole-inspect.md) - Inspect URL index status (URL Inspection API) - [`gog searchconsole (gsc,search-console,webmasters) query (report) [flags]`](commands/gog-searchconsole-query.md) - Run a Search Analytics query - [`gog searchconsole (gsc,search-console,webmasters) searchanalytics (analytics) `](commands/gog-searchconsole-searchanalytics.md) - Search Analytics queries - [`gog searchconsole (gsc,search-console,webmasters) searchanalytics (analytics) query (run) [flags]`](commands/gog-searchconsole-searchanalytics-query.md) - Run a Search Analytics query @@ -621,6 +622,7 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) datasource (data-source,data-sources,connected-sheets) update [flags]`](commands/gog-sheets-datasource-update.md) - Update one BigQuery Connected Sheets data source - [`gog sheets (sheet) delete-dimension (delete-dim) --dimension=STRING [flags]`](commands/gog-sheets-delete-dimension.md) - Delete rows or columns while preserving intersecting tables - [`gog sheets (sheet) delete-tab (delete-sheet) `](commands/gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) + - [`gog sheets (sheet) duplicate-tab [flags]`](commands/gog-sheets-duplicate-tab.md) - Duplicate a tab within a spreadsheet - [`gog sheets (sheet) export (download,dl) [flags]`](commands/gog-sheets-export.md) - Export a Google Sheet (pdf|xlsx|csv) via Drive - [`gog sheets (sheet) filter (filters,basic-filter,basic-filters) `](commands/gog-sheets-filter.md) - Manage basic filters - [`gog sheets (sheet) filter (filters,basic-filter,basic-filters) set (create,add) `](commands/gog-sheets-filter-set.md) - Set a basic filter on a range; replacing an existing filter requires confirmation (or --force) @@ -680,7 +682,7 @@ Generated from `gog schema --json`. - [`gog slides (slide) element alt-text [flags]`](commands/gog-slides-element-alt-text.md) - Set or clear element accessibility text - [`gog slides (slide) element create-line [flags]`](commands/gog-slides-element-create-line.md) - Create a native line on a slide - [`gog slides (slide) element create-shape [flags]`](commands/gog-slides-element-create-shape.md) - Create a native shape on a slide - - [`gog slides (slide) element delete (rm) `](commands/gog-slides-element-delete.md) - Delete one page element + - [`gog slides (slide) element delete (rm) ...`](commands/gog-slides-element-delete.md) - Delete one or more page elements - [`gog slides (slide) element group ... [flags]`](commands/gog-slides-element-group.md) - Group two or more elements - [`gog slides (slide) element style [flags]`](commands/gog-slides-element-style.md) - Style a shape fill/outline or a line - [`gog slides (slide) element transform (move,resize,rotate) [flags]`](commands/gog-slides-element-transform.md) - Move, resize, rotate, or replace an element transform @@ -695,6 +697,7 @@ Generated from `gog schema --json`. - [`gog slides (slide) locate (find-element) [flags]`](commands/gog-slides-locate.md) - Locate text in shapes and table cells with object IDs and UTF-16 ranges - [`gog slides (slide) move-slide --to-index=TO-INDEX `](commands/gog-slides-move-slide.md) - Move a slide to a zero-based insertion index - [`gog slides (slide) new-slide [flags]`](commands/gog-slides-new-slide.md) - Create a native themed slide + - [`gog slides (slide) paragraph-style [flags]`](commands/gog-slides-paragraph-style.md) - Set paragraph alignment, spacing, indentation, or direction - [`gog slides (slide) raw [flags]`](commands/gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [`gog slides (slide) read-slide [flags]`](commands/gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [`gog slides (slide) replace-slide [] [flags]`](commands/gog-slides-replace-slide.md) - Replace an existing slide image from a local file or public URL diff --git a/docs/commands/README.md b/docs/commands/README.md index 85210816b..149c1aae3 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: 766. +Generated pages: 769. ## Top-level Commands @@ -628,6 +628,7 @@ Generated pages: 766. - [gog schema](gog-schema.md) - Machine-readable command/flag schema - [gog search](gog-search.md) - Search Drive files (alias for 'drive search') - [gog searchconsole](gog-searchconsole.md) - Google Search Console + - [gog searchconsole inspect](gog-searchconsole-inspect.md) - Inspect URL index status (URL Inspection API) - [gog searchconsole query](gog-searchconsole-query.md) - Run a Search Analytics query - [gog searchconsole searchanalytics](gog-searchconsole-searchanalytics.md) - Search Analytics queries - [gog searchconsole searchanalytics query](gog-searchconsole-searchanalytics-query.md) - Run a Search Analytics query @@ -675,6 +676,7 @@ Generated pages: 766. - [gog sheets datasource update](gog-sheets-datasource-update.md) - Update one BigQuery Connected Sheets data source - [gog sheets delete-dimension](gog-sheets-delete-dimension.md) - Delete rows or columns while preserving intersecting tables - [gog sheets delete-tab](gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) + - [gog sheets duplicate-tab](gog-sheets-duplicate-tab.md) - Duplicate a tab within a spreadsheet - [gog sheets export](gog-sheets-export.md) - Export a Google Sheet (pdf|xlsx|csv) via Drive - [gog sheets filter](gog-sheets-filter.md) - Manage basic filters - [gog sheets filter set](gog-sheets-filter-set.md) - Set a basic filter on a range; replacing an existing filter requires confirmation (or --force) @@ -734,7 +736,7 @@ Generated pages: 766. - [gog slides element alt-text](gog-slides-element-alt-text.md) - Set or clear element accessibility text - [gog slides element create-line](gog-slides-element-create-line.md) - Create a native line on a slide - [gog slides element create-shape](gog-slides-element-create-shape.md) - Create a native shape on a slide - - [gog slides element delete](gog-slides-element-delete.md) - Delete one page element + - [gog slides element delete](gog-slides-element-delete.md) - Delete one or more page elements - [gog slides element group](gog-slides-element-group.md) - Group two or more elements - [gog slides element style](gog-slides-element-style.md) - Style a shape fill/outline or a line - [gog slides element transform](gog-slides-element-transform.md) - Move, resize, rotate, or replace an element transform @@ -749,6 +751,7 @@ Generated pages: 766. - [gog slides locate](gog-slides-locate.md) - Locate text in shapes and table cells with object IDs and UTF-16 ranges - [gog slides move-slide](gog-slides-move-slide.md) - Move a slide to a zero-based insertion index - [gog slides new-slide](gog-slides-new-slide.md) - Create a native themed slide + - [gog slides paragraph-style](gog-slides-paragraph-style.md) - Set paragraph alignment, spacing, indentation, or direction - [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [gog slides read-slide](gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [gog slides replace-slide](gog-slides-replace-slide.md) - Replace an existing slide image from a local file or public URL diff --git a/docs/commands/gog-api-call.md b/docs/commands/gog-api-call.md index 1156d49b7..81e36eea2 100644 --- a/docs/commands/gog-api-call.md +++ b/docs/commands/gog-api-call.md @@ -33,6 +33,7 @@ gog api call [flags] | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | | `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-cache` | `bool` | | Fetch the Discovery document without reading or writing the 24-hour disk cache | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--params` | `string` | {} | JSON object of path and query parameters | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | diff --git a/docs/commands/gog-api-describe.md b/docs/commands/gog-api-describe.md index fde5cd300..f1f1e7c6f 100644 --- a/docs/commands/gog-api-describe.md +++ b/docs/commands/gog-api-describe.md @@ -7,7 +7,7 @@ Describe a Discovery API or method ## Usage ```bash -gog api describe [] +gog api describe [] [flags] ``` ## Parent @@ -31,6 +31,7 @@ gog api describe [] | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | | `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-cache` | `bool` | | Fetch the Discovery document without reading or writing the 24-hour disk cache | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--quota-project` | `string` | | Google Cloud project to bill for API usage (sent as X-Goog-User-Project; some APIs require it with --access-token or ADC) | diff --git a/docs/commands/gog-drive-ls.md b/docs/commands/gog-drive-ls.md index 38d5445ab..3b846d1c4 100644 --- a/docs/commands/gog-drive-ls.md +++ b/docs/commands/gog-drive-ls.md @@ -36,6 +36,7 @@ gog drive (drv) ls [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--max`
`--limit` | `int64` | 20 | Max results | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--order` | `string` | desc | Sort direction | | `--page`
`--cursor` | `string` | | Page token | | `--parent` | `string` | | Folder ID to list (default: root) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | @@ -44,6 +45,7 @@ gog drive (drv) ls [flags] | `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--sort` | `string` | modifiedTime | Drive API sort key | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | diff --git a/docs/commands/gog-ls.md b/docs/commands/gog-ls.md index 1828b9015..eda5e87d7 100644 --- a/docs/commands/gog-ls.md +++ b/docs/commands/gog-ls.md @@ -36,6 +36,7 @@ gog ls (list) [flags] | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | | `--max`
`--limit` | `int64` | 20 | Max results | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--order` | `string` | desc | Sort direction | | `--page`
`--cursor` | `string` | | Page token | | `--parent` | `string` | | Folder ID to list (default: root) | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | @@ -44,6 +45,7 @@ gog ls (list) [flags] | `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--sort` | `string` | modifiedTime | Drive API sort key | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | diff --git a/docs/commands/gog-searchconsole-inspect.md b/docs/commands/gog-searchconsole-inspect.md new file mode 100644 index 000000000..865581ba0 --- /dev/null +++ b/docs/commands/gog-searchconsole-inspect.md @@ -0,0 +1,48 @@ +# `gog searchconsole inspect` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Inspect URL index status (URL Inspection API) + +## Usage + +```bash +gog searchconsole (gsc,search-console,webmasters) inspect [flags] +``` + +## Parent + +- [gog searchconsole](gog-searchconsole.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--language` | `string` | en-US | BCP-47 language for issue messages (e.g. zh-TW) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--quota-project` | `string` | | Google Cloud project to bill for API usage (sent as X-Goog-User-Project; some APIs require it with --access-token or ADC) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog searchconsole](gog-searchconsole.md) +- [Command index](README.md) diff --git a/docs/commands/gog-searchconsole.md b/docs/commands/gog-searchconsole.md index 48691adc0..a37d54a6a 100644 --- a/docs/commands/gog-searchconsole.md +++ b/docs/commands/gog-searchconsole.md @@ -16,6 +16,7 @@ gog searchconsole (gsc,search-console,webmasters) [flags] ## Subcommands +- [gog searchconsole inspect](gog-searchconsole-inspect.md) - Inspect URL index status (URL Inspection API) - [gog searchconsole query](gog-searchconsole-query.md) - Run a Search Analytics query - [gog searchconsole searchanalytics](gog-searchconsole-searchanalytics.md) - Search Analytics queries - [gog searchconsole sitemaps](gog-searchconsole-sitemaps.md) - List/get/submit/delete sitemaps diff --git a/docs/commands/gog-sheets-duplicate-tab.md b/docs/commands/gog-sheets-duplicate-tab.md new file mode 100644 index 000000000..ac6296020 --- /dev/null +++ b/docs/commands/gog-sheets-duplicate-tab.md @@ -0,0 +1,48 @@ +# `gog sheets duplicate-tab` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Duplicate a tab within a spreadsheet + +## Usage + +```bash +gog sheets (sheet) duplicate-tab [flags] +``` + +## Parent + +- [gog sheets](gog-sheets.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--index` | `*int64` | | Zero-based insertion index (default: directly after the source tab) | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--quota-project` | `string` | | Google Cloud project to bill for API usage (sent as X-Goog-User-Project; some APIs require it with --access-token or ADC) | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets](gog-sheets.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets-raw.md b/docs/commands/gog-sheets-raw.md index 9308120ea..3b119f953 100644 --- a/docs/commands/gog-sheets-raw.md +++ b/docs/commands/gog-sheets-raw.md @@ -39,6 +39,7 @@ gog sheets (sheet) raw [flags] | `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--sheet` | `string` | | Return only this sheet (exact tab title); spreadsheet-level metadata remains included | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | diff --git a/docs/commands/gog-sheets.md b/docs/commands/gog-sheets.md index 3a67954a2..daafa520f 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -29,6 +29,7 @@ gog sheets (sheet) [flags] - [gog sheets datasource](gog-sheets-datasource.md) - Manage Connected Sheets data sources and extracts - [gog sheets delete-dimension](gog-sheets-delete-dimension.md) - Delete rows or columns while preserving intersecting tables - [gog sheets delete-tab](gog-sheets-delete-tab.md) - Delete a tab/sheet from a spreadsheet (use --force to skip confirmation) +- [gog sheets duplicate-tab](gog-sheets-duplicate-tab.md) - Duplicate a tab within a spreadsheet - [gog sheets export](gog-sheets-export.md) - Export a Google Sheet (pdf|xlsx|csv) via Drive - [gog sheets filter](gog-sheets-filter.md) - Manage basic filters - [gog sheets find-replace](gog-sheets-find-replace.md) - Find and replace text across a spreadsheet diff --git a/docs/commands/gog-slides-element-delete.md b/docs/commands/gog-slides-element-delete.md index ed01c4815..10d20aa70 100644 --- a/docs/commands/gog-slides-element-delete.md +++ b/docs/commands/gog-slides-element-delete.md @@ -2,12 +2,12 @@ > Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. -Delete one page element +Delete one or more page elements ## Usage ```bash -gog slides (slide) element delete (rm) +gog slides (slide) element delete (rm) ... ``` ## Parent diff --git a/docs/commands/gog-slides-element.md b/docs/commands/gog-slides-element.md index d3e1b4048..57f8c4a31 100644 --- a/docs/commands/gog-slides-element.md +++ b/docs/commands/gog-slides-element.md @@ -19,7 +19,7 @@ gog slides (slide) element - [gog slides element alt-text](gog-slides-element-alt-text.md) - Set or clear element accessibility text - [gog slides element create-line](gog-slides-element-create-line.md) - Create a native line on a slide - [gog slides element create-shape](gog-slides-element-create-shape.md) - Create a native shape on a slide -- [gog slides element delete](gog-slides-element-delete.md) - Delete one page element +- [gog slides element delete](gog-slides-element-delete.md) - Delete one or more page elements - [gog slides element group](gog-slides-element-group.md) - Group two or more elements - [gog slides element style](gog-slides-element-style.md) - Style a shape fill/outline or a line - [gog slides element transform](gog-slides-element-transform.md) - Move, resize, rotate, or replace an element transform diff --git a/docs/commands/gog-slides-insert-text.md b/docs/commands/gog-slides-insert-text.md index 6c1207911..b0a054753 100644 --- a/docs/commands/gog-slides-insert-text.md +++ b/docs/commands/gog-slides-insert-text.md @@ -37,7 +37,7 @@ gog slides (slide) insert-text [flags] | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--quota-project` | `string` | | Google Cloud project to bill for API usage (sent as X-Goog-User-Project; some APIs require it with --access-token or ADC) | | `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | -| `--replace` | `bool` | | Clear existing text in the element before inserting (emits DeleteText + InsertText in the same batch) | +| `--replace` | `bool` | | Replace existing text while inheriting its leading text style (revision-checked atomic batch) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--row` | `*int64` | | 0-based table row index for cell-targeted text; requires --col | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | diff --git a/docs/commands/gog-slides-paragraph-style.md b/docs/commands/gog-slides-paragraph-style.md new file mode 100644 index 000000000..e0e78879d --- /dev/null +++ b/docs/commands/gog-slides-paragraph-style.md @@ -0,0 +1,58 @@ +# `gog slides paragraph-style` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Set paragraph alignment, spacing, indentation, or direction + +## Usage + +```bash +gog slides (slide) paragraph-style [flags] +``` + +## Parent + +- [gog slides](gog-slides.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands | +| `--align` | `string` | | Paragraph alignment: START, CENTER, END, JUSTIFIED | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--col` | `*int64` | | Zero-based table column; requires --row | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--direction` | `string` | | Text direction: LEFT_TO_RIGHT or RIGHT_TO_LEFT | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--indent-end` | `*float64` | | Paragraph end indentation in points | +| `--indent-first-line` | `*float64` | | First-line indentation in points | +| `--indent-start` | `*float64` | | Paragraph start indentation in points | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--line-spacing` | `*float64` | | Line spacing percent (100 is normal) | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--quota-project` | `string` | | Google Cloud project to bill for API usage (sent as X-Goog-User-Project; some APIs require it with --access-token or ADC) | +| `--range` | `string` | | UTF-16 range as start:end (default: all text); styles every intersecting paragraph | +| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--row` | `*int64` | | Zero-based table row; requires --col | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--space-above` | `*float64` | | Space above each paragraph in points | +| `--space-below` | `*float64` | | Space below each paragraph in points | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog slides](gog-slides.md) +- [Command index](README.md) diff --git a/docs/commands/gog-slides.md b/docs/commands/gog-slides.md index a7643ec5f..db5c55d89 100644 --- a/docs/commands/gog-slides.md +++ b/docs/commands/gog-slides.md @@ -34,6 +34,7 @@ gog slides (slide) [flags] - [gog slides locate](gog-slides-locate.md) - Locate text in shapes and table cells with object IDs and UTF-16 ranges - [gog slides move-slide](gog-slides-move-slide.md) - Move a slide to a zero-based insertion index - [gog slides new-slide](gog-slides-new-slide.md) - Create a native themed slide +- [gog slides paragraph-style](gog-slides-paragraph-style.md) - Set paragraph alignment, spacing, indentation, or direction - [gog slides raw](gog-slides-raw.md) - Dump raw Google Slides API response as JSON (Presentations.Get; lossless; for scripting and LLM consumption) - [gog slides read-slide](gog-slides-read-slide.md) - Read slide content: speaker notes, text elements, and images - [gog slides replace-slide](gog-slides-replace-slide.md) - Replace an existing slide image from a local file or public URL diff --git a/docs/docs-editing.md b/docs/docs-editing.md index 88a19c838..5f92856ca 100644 --- a/docs/docs-editing.md +++ b/docs/docs-editing.md @@ -18,7 +18,7 @@ gog docs write --append --markdown --text '## Status' Replace the document body with Markdown from a file: ```bash -gog docs write --replace --markdown --content-file README.md +gog docs write --replace --markdown --file README.md ``` The local Markdown renderer keeps headings adjacent to their following body, diff --git a/docs/examples.md b/docs/examples.md index b1a8cd227..cafeb7a92 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -66,6 +66,8 @@ See [Drive audits](drive-audits.md), [polling](polling.md), and ```bash # Read-only folder audits. +gog drive ls --sort modifiedByMeTime --order desc +gog drive ls --sort name_natural --order asc gog drive tree --parent --depth 2 gog drive du --parent --max 20 --json gog drive inventory --parent --json @@ -192,6 +194,7 @@ See [batch updates](sheets-batch-update.md), [tables](sheets-tables.md), and ```bash gog sheets get 'Sheet1!A1:D20' --json +gog sheets duplicate-tab Sheet1 "Sheet1 backup" --json gog sheets update 'Sheet1!B13' \ --values-json @formula.json --fail-on-formula-error --json gog sheets batch-update --data-json @updates.json --json diff --git a/docs/quickstart.md b/docs/quickstart.md index 5f686f01a..4ef3384f2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -107,13 +107,26 @@ gog auth doctor --check ## 5. Set a default account +For a persistent default, open the account manager and click **Set default** +beside the account you want to use: + +```bash +gog auth manage +``` + +The stored default applies when neither `--account` nor `GOG_ACCOUNT` is set. +If you previously exported `GOG_ACCOUNT`, run `unset GOG_ACCOUNT` to use the +stored default. + +Alternatively, select an account for your shell with: + ```bash export GOG_ACCOUNT=you@gmail.com -# or persist a default with gog auth alias -gog auth alias set default you@gmail.com ``` -Now you can drop `--account` from every command. +Either approach lets you omit `--account` from commands. Account aliases such +as `work` are shortcuts for explicit selection; `default` and `auto` are +reserved names, not aliases you can create. ## 6. Run real commands diff --git a/docs/raw-api.md b/docs/raw-api.md index 26812526f..1c3058aba 100644 --- a/docs/raw-api.md +++ b/docs/raw-api.md @@ -32,8 +32,15 @@ gog docs raw --tab "Notes" --pretty gog docs raw --all-tabs --json > doc-tabs-api.json gog gmail raw --format metadata --json gog sheets raw --include-grid-data --json +gog sheets raw --sheet "Quarterly Data" --include-grid-data --json ``` +`gog sheets raw --sheet` selects one exact tab title on the server, reducing +the response before it is downloaded. Titles such as `A1` and titles containing +apostrophes are treated as tab names. Spreadsheet-level metadata remains in +the raw response; grid data still requires `--include-grid-data`. Omitting +`--sheet` returns every tab as before. + `gog docs raw --tab` resolves a tab title or ID and projects that tab into the legacy top-level `Document` fields such as `body`, `lists`, and `inlineObjects`. `--all-tabs` keeps the canonical `Documents.Get` response and diff --git a/docs/refactor/README.md b/docs/refactor/README.md index 504ac0b85..8da44006d 100644 --- a/docs/refactor/README.md +++ b/docs/refactor/README.md @@ -1,19 +1,17 @@ --- -summary: "Refactor notes (implementation status + next wins)" +summary: "Shared implementation patterns" read_when: - Touching exports/output/templates - Planning cleanup work --- -# Refactor notes +# Shared implementation patterns -Shipped (today) +These notes describe existing code to reuse when changing adjacent commands: -- `exports.md`: Drive-backed export command pattern (`docs|slides|sheets`). -- `output.md`: shared table + paging helpers. -- `templates.md`: googleauth HTML templates via `//go:embed`. - -Backlog / next wins - -- `options.md`: ideas; pick + execute when touching adjacent code. +- [Exports](exports.md): Drive-backed Docs, Slides, and Sheets downloads. +- [Output](output.md): invocation-aware table, result, and paging output. +- [Templates](templates.md): embedded Google authorization HTML. +- [Ownership](options.md): service setup, pagination, and retry boundaries. +See [Architecture and contracts](../spec.md) for the command and package layout. diff --git a/docs/refactor/exports.md b/docs/refactor/exports.md index e56b7ba8a..0a97dccb0 100644 --- a/docs/refactor/exports.md +++ b/docs/refactor/exports.md @@ -11,8 +11,8 @@ Goal: one implementation for “export Google *Thing* via Drive”. ## Current pattern -- Shared builder: `internal/cmd/export_via_drive.go:newExportViaDriveCmd` -- Shared download: `internal/cmd/drive.go:downloadDriveFile` (handles Drive “native” exports + normal files) +- Shared orchestration: `internal/cmd/export_via_drive.go:exportViaDrive` +- Shared download: `internal/cmd/drive_download.go:downloadDriveFile` (handles Drive “native” exports + normal files) Each service command is a thin wrapper: @@ -26,7 +26,7 @@ Exception: `gog docs export --tab ` exports a single Google Docs ta - Arg is always the Drive file id (Doc/Sheet/Slides id). - Type guard: compare `mimeType` and error with `file is not a (mimeType="...")`. -- `--out` defaults to `$(os.UserConfigDir())/gogcli/drive-downloads/` (via `internal/config:EnsureDriveDownloadsDir`). +- `--out` defaults to `$(os.UserConfigDir())/gogcli/drive-downloads/` (via the resolved config layout). - `--out` can be an existing directory, a new directory ending in `/`, or an explicit file path (via `internal/cmd/drive_download_helpers.go:resolveDriveDownloadDestPath`); missing parent directories are created only when writing the download. - `--out -` writes export bytes to stdout; JSON mode rejects it to avoid mixing metadata with bytes. - Output @@ -37,5 +37,5 @@ Exception: `gog docs export --tab ` exports a single Google Docs ta ## Add a new export command 1) Pick expected Drive mime type + allowed formats. -2) Add a new `newXExportCmd` calling `newExportViaDriveCmd(...)`. +2) Add a Kong command struct whose `Run` method calls `exportViaDrive` with `exportViaDriveOptions`. 3) Add/extend tests in `internal/cmd/execute_drive_*_test.go` style (fake Drive server). diff --git a/docs/refactor/options.md b/docs/refactor/options.md index 1b9105de7..c6ec93650 100644 --- a/docs/refactor/options.md +++ b/docs/refactor/options.md @@ -1,25 +1,26 @@ --- -summary: "Refactor options (next wins)" +summary: "Shared command helpers and ownership boundaries" read_when: - Planning cleanup work - Touching retry/logging/output plumbing --- -# Refactor options (next wins) +# Shared command helpers -Small wins +Before adding a helper, check the existing owner: -- “List + page” helper: generic wrapper for `--max/--page` + `nextPageToken` output. -- Standardize list headers: consistent column naming (ID/NAME/EMAIL/etc). -- “Output row” helpers: centralize `sanitizeTab` use for tabular output. +- `internal/cmd/service_helpers.go` selects accounts and initializes services. +- `internal/cmd/paging.go` collects bounded or unbounded result pages; + `paging_guard.go` rejects repeated tokens for command-specific loops. +- `internal/cmd/output_helpers.go` routes table and result output through the + invocation context and prints pagination hints on stderr. +- `internal/cmd/drive_download.go` resolves Drive export formats; + `export_via_drive.go` orchestrates typed document exports. +- `internal/googleapi/transport.go` handles HTTP retries and replayable bodies. + `WithoutRetries` is available for operations whose callers require a single + attempt; do not add a second generic retry loop around it. -Medium wins - -- Drive “export format” registry: single map for docs/sheets/slides format help + validation. -- Shared “service bootstrap” helpers: reduce per-command boilerplate for `requireAccount` + `newXService`. -- Test harness helpers: one fake Google API server util (Drive/Gmail/Calendar/Tasks) with common JSON assertions. - -Bigger wins - -- API client retry unification: one retry stack (transport vs explicit); delete the other; push logs behind `--verbose`. -- Command grouping / UX: consolidate “download/export” story; ensure help text + flags match across services. +Keep service-specific pagination limits, partial-result rules, and output +shapes at the command boundary. Share mechanics without silently changing +those contracts. Existing fake-server tests next to the affected commands +provide request and response examples. diff --git a/docs/refactor/output.md b/docs/refactor/output.md index 58c64602e..6dc980ac0 100644 --- a/docs/refactor/output.md +++ b/docs/refactor/output.md @@ -13,7 +13,7 @@ Goal: kill copy/paste; keep output consistent. Use `internal/cmd/output_helpers.go:tableWriter(ctx)`: -- `--plain`: `os.Stdout` (no alignment, TSV-friendly) +- `--plain`: the invocation stdout writer (no alignment, TSV-friendly) - default: `tabwriter.Writer` (aligned columns) Call pattern: diff --git a/docs/slides-structure.md b/docs/slides-structure.md index 73cf3fe56..640314427 100644 --- a/docs/slides-structure.md +++ b/docs/slides-structure.md @@ -108,6 +108,7 @@ gog slides element ungroup ... gog slides element alt-text \ --title "Chart" --description "Quarterly revenue by region" gog slides element delete --force +gog slides element delete --force ``` `z-order` targets must share one slide and must not be grouped. `group` needs at @@ -115,3 +116,9 @@ least two ungrouped elements on one slide; Slides does not permit every element kind to be grouped. Passing an empty `--title=` or `--description=` clears that alt-text field. Element deletion is destructive and requires confirmation or `--force` in non-interactive use. Every mutation supports `--dry-run --json`. + +Multiple deletion targets are submitted together in one atomic API batch; +duplicate IDs are rejected before submission. A single target retains the +existing `objectId` JSON field; multiple targets return `objectIds` instead. +Text replacement does not edit WordArt text. To replace a WordArt heading, +delete its element and create a text box with the desired text. diff --git a/docs/slides-text-editing.md b/docs/slides-text-editing.md index 7e84a9cd7..ad72ad3b9 100644 --- a/docs/slides-text-editing.md +++ b/docs/slides-text-editing.md @@ -1,5 +1,12 @@ # Slides text editing +`insert-text --replace` inserts before removing the old text so replacement +text inherits the leading visible text's style, including template inheritance. +An empty target receives only an insertion. The command reads the target first +and pins its revision for the atomic update. Google-stripped control/private-use +characters are removed before calculating UTF-16 deletion offsets; an empty +replacement clears the target. Dry runs remain auth-free previews. + Slides text ranges use UTF-16 code-unit indexes. Find exact element IDs and ranges before changing a deck: @@ -29,6 +36,22 @@ auth or API access. ## Replace text safely +`paragraph-style` formats all paragraphs in one shape, or the paragraphs +intersecting an optional UTF-16 range. It also supports one table cell: + +```bash +gog slides paragraph-style --align START --line-spacing 120 +gog slides paragraph-style --range 0:20 --space-above 0 --space-below 8 +gog slides paragraph-style --indent-start 18 --indent-first-line 0 +gog slides paragraph-style --row 0 --col 1 --align CENTER +``` + +Spacing and indentation are in points; line spacing is a percentage (100 is +normal). Only supplied fields are changed, including explicit zero values. +Use `--direction LEFT_TO_RIGHT` or `RIGHT_TO_LEFT` for paragraph direction. +Table cells are validated against the current presentation and updated with +revision protection. Dry runs require no authentication. + `replace-text` requires an explicit scope: ```bash diff --git a/docs/spec.md b/docs/spec.md index 1e516e6c6..34842cc19 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1,597 +1,90 @@ -# gogcli spec +# Architecture and contracts -## Goal +`gog` is a Go CLI for Google Workspace and related APIs. The command tree is +built with Kong in `internal/cmd`; `cmd/gog` is the process entrypoint. The Go +minimum and preferred toolchain are declared in `go.mod`. -Build a single, clean, modern Go CLI that talks to: +The running command tree is the source of truth for command names, arguments, +flags, and aliases: -- Gmail API -- Google Calendar API -- Google Chat API -- Google Classroom API -- Google Drive API -- Google Drive Labels API -- Google Docs API -- Google Sheets API -- Google Forms API -- Google Maps Places API -- Google Photos Library API -- Google Photos Picker API -- Apps Script API -- Google Tasks API -- Cloud Identity API (Groups) -- Google People API (Contacts + directory) -- Google Keep API (Workspace-only, service account) +```bash +gog schema --json +gog schema gmail search --json +gog help docs write +``` -This replaces the existing separate CLIs (`gmcli`, `gccli`, `gdcli`) and the Python contacts server conceptually, but: +Generated [command references](commands/README.md) and +[agent skills](agent-skills.md) come from that schema. Update command structs +first, then regenerate with `make docs-commands` and `make agent-skills`. -- no backwards compatibility -- no migration tooling +## Command boundaries -## Non-goals +Commands validate local inputs before creating API clients or mutating state. +Shared service helpers select the account and construct authenticated clients; +request planners in packages such as `docsedit`, `docssed`, and `sheetsdimension` +keep API request construction separate from command orchestration. -- Preserving legacy command names/flags/output formats -- Importing existing `~/.gmcli`, `~/.gccli`, `~/.gdcli` state -- Exposing the whole CLI through a generic MCP command-execution bridge +Docs editing commands are grouped by operation in `internal/cmd/docs_write.go`, +`docs_write_markdown.go`, `docs_update.go`, `docs_insert.go`, `docs_delete.go`, +and `docs_find_replace.go`. `docs_edit.go` retains the `docs edit` command +wrapper and shared deprecated-tab flag resolution. See +[Docs editing](docs-editing.md) for the user-facing behavior and +[persisted batches](docs-batch.md) for revision and commit semantics. -## MCP server +Whole-document Docs, Sheets, and Slides exports share +`internal/cmd/export_via_drive.go`. The experimental single-tab Docs export +uses the Docs web endpoint instead; see [export conventions](https://github.com/openclaw/gogcli/blob/main/docs/refactor/exports.md). -`gog mcp` runs a typed MCP server over stdio for agent clients that need a -permissioned Google Workspace tool surface. It intentionally does not expose a -generic shell/argv bridge. Each MCP tool has a fixed schema and maps to a -specific `gog` operation. +The generic Discovery API surface has its own request validation, host guards, +and bounded document cache in `internal/discoveryapi`. See [Raw API](raw-api.md) +and [Automation](automation.md#discovery-document-cache). -MCP defaults are read-only. Write tools are hidden unless the server is started -with `--allow-write`, and `--allow-tool` can further narrow the registered tool -set by tool name or service prefix. Parent root context such as `--account`, -`--home`, output mode, `--no-input`, untrusted wrapping, and command safety -flags is preserved for subprocess calls. +## Authentication and storage -## Language/runtime +`internal/googleauth` owns OAuth flows, service scopes, and account selection; +`internal/googleapi` constructs service clients and owns the shared retry +transport. Browser, manual, remote, and account-manager authorization use S256 +PKCE. Stored tokens track Google's OIDC subject when available and preserve +legacy email-keyed lookup compatibility. -- Go `1.26` (see `go.mod`) +`internal/config` resolves configuration, data, state, and cache paths. +`internal/secrets` owns platform keyring and encrypted file storage. Do not +introduce a separate plaintext token store or bypass the existing keyring +locking and timeout behavior. -## CLI framework +The detailed contracts live in: -- `github.com/alecthomas/kong` -- Root command: `gog` -- Global flag: - - `--color=auto|always|never` (default `auto`) - - `--json` (JSON output to stdout) - - `--plain` (TSV output to stdout; stable/parseable; disables colors) - - `--force` (skip confirmations for destructive commands) - - `--no-input` (never prompt; fail instead) - - `--version` (print version) +- [OAuth clients](auth-clients.md): account/client routing, scopes, service accounts. +- [Paths and state](paths.md): overrides, XDG layout, and legacy reads. +- [Quickstart](quickstart.md): OAuth setup and authorization. +- The generated [OAuth service table](https://github.com/openclaw/gogcli#supported-oauth-services). -Notes: +## Output and safety -- We run `SilenceUsage: true` and print errors ourselves (colored when possible). -- `NO_COLOR` is respected. +`internal/outfmt` and the command output helpers keep JSON and TSV on stdout; +`internal/ui` sends progress and diagnostics to stderr. Use the invocation's +context writer so embedded commands and tests preserve output routing. -Environment: +[Automation](automation.md) defines JSON projection, output precedence, exit +codes, retries, dry runs, and read-only behavior. [Safety profiles](safety-profiles.md) +define baked command policy and locked flags. Existing CLI flags, aliases, +configuration keys, and storage formats are compatibility contracts. -- `GOG_COLOR=auto|always|never` (default `auto`, overridden by `--color`) -- `GOG_JSON=1` (default JSON output; overridden by flags) -- `GOG_PLAIN=1` (default plain output; overridden by flags) +The [MCP server](mcp.md) exposes a typed stdio tool surface with read-only +defaults and explicit write authorization. It does not expose arbitrary shell +or argv execution. -## Output (TTY-aware colors) +## Development gates -- `github.com/muesli/termenv` is used to detect rich TTY capabilities and render colored output. -- Colors are enabled when: - - output is a rich terminal and `--color=auto`, and `NO_COLOR` is not set; or - - `--color=always` -- Colors are disabled when: - - `--color=never`; or - - `NO_COLOR` is set +`Makefile` owns development-tool pins and local gates; `.golangci.yml` configures +linting against the minimum Go version. `make fmt` applies goimports and +gofumpt; `make fmt-check` reports formatting differences without editing files. -Implementation: `internal/ui/ui.go`. +Run `make ci` for formatting, lint, deadcode, Go and script tests, Docker +version consistency, documentation coverage, and generated agent skills. The +tracking worker has a separate pnpm workspace and `make worker-ci` gate. +GitHub workflows under `.github/workflows` define the platform matrix. -## Auth + secret storage - -### OAuth client credentials (non-secret-ish) - -- Stored on disk in the per-user config directory: - - `$(os.UserConfigDir())/gogcli/credentials.json` (default client) - - `$(os.UserConfigDir())/gogcli/credentials-.json` (named clients) -- Written with mode `0600`. -- Command: - - `gog auth credentials ` - - `gog --client auth credentials ` - - `gog auth credentials list` - - `gog auth credentials remove [|all]` -- Supports Google’s downloaded JSON format: - - `installed.client_id/client_secret` or `web.client_id/client_secret` - -Implementation: `internal/config/*`. - -### Refresh tokens (secrets) - -- Stored in OS credential store via `github.com/99designs/keyring`. -- Key namespace is `gogcli` by default (keyring `ServiceName`); override with `GOG_KEYRING_SERVICE_NAME`. -- Key format: `token::` (default client uses `token:default:`) -- Canonical identity key format for new tokens with an OIDC subject: `token-sub::`. Email-keyed entries remain as compatibility lookup keys. -- Legacy key format: `token:` (migrated on first read) -- Stored payload is JSON (refresh token + metadata like OIDC subject, current email, selected services/scopes). -- Email is treated as display/contact state; Google's OIDC `sub` is used to detect the same account after an email rename and migrate aliases/defaults/client mappings on reauthorization. -- Keyring operations are bounded by a timeout (default `30s` on macOS and `10s` elsewhere, configurable via `GOG_KEYRING_OPEN_TIMEOUT`) so non-surfacing permission prompts and unresponsive backends return actionable guidance instead of hanging indefinitely. -- Fallback: if no OS credential store is available, keyring may use its encrypted "file" backend: - - Directory: `$(os.UserConfigDir())/gogcli/keyring/` (one file per key; gog-managed key names are encoded for portable filenames) - - Password: prompts on TTY; for non-interactive runs set `GOG_KEYRING_PASSWORD` - -Current minimal management commands (implemented): - -- `gog auth tokens list` (keys only; does not decrypt token payloads) -- `gog auth tokens delete ` -- `gog auth list` reports unreadable token entries instead of failing the whole listing, so one bad file-keyring entry does not hide other accounts. - -Implementation: `internal/secrets/store.go`. - -### OAuth flow - -- Desktop OAuth 2.0 flow using local HTTP redirect on an ephemeral port. -- Supports a browserless/manual flow (paste redirect URL) for headless environments. -- Supports a remote/server-friendly 2-step manual flow: - - Step 1 prints an auth URL (`gog auth add ... --remote --step 1`) - - Step 2 exchanges the pasted redirect URL and requires `state` validation (`--remote --step 2 --auth-url ...`) - - Browser, manual, remote, and account-manager flows bind authorization - requests and token exchanges with S256 PKCE. - - Remote steps must share the same config home and OAuth client. Unfinished - pre-v0.24.0 flows must restart at step 1. -- Refresh token issuance: - - requests `access_type=offline` - - supports `--force-consent` to force the consent prompt when Google doesn't return a refresh token - - uses `include_granted_scopes=true` to support incremental auth re-runs for full-scope authorization - - omits `include_granted_scopes` for limited scope presets and reauthorization of known narrow Gmail grants so broader historical permissions are not silently restored - -Scope selection note: - -- The consent screen shows the scopes the CLI requested. -- Users cannot selectively un-check individual requested scopes in the consent screen; they either approve all requested scopes or cancel. -- To request fewer scopes, choose fewer services via `gog auth add --services ...`, use `gog auth add --readonly`, or select a limited service preset such as `--gmail-scope readonly|send|read-send`. - -## Config layout - -- Base config dir: `$(os.UserConfigDir())/gogcli/` -- Files: - - `config.json` (optional preferences; JSON5; comments and trailing commas allowed) - - `credentials.json` (OAuth client id/secret; default client) - - `credentials-.json` (OAuth client id/secret; named clients) -- State: - - `state/gmail-watch/.json` (Gmail watch state) - - `oauth-manual-state-.json` (temporary manual OAuth state and PKCE verifier cache; expires quickly; no tokens) -- Secrets: - - refresh tokens in keyring - -We intentionally avoid storing refresh tokens in plain JSON on disk. - -Environment: - -- `GOG_ACCOUNT=you@gmail.com` (email or alias; used when `--account` is not set; otherwise uses keyring default or a single stored token) -- `GOG_CLIENT=work` (select OAuth client bucket; see `--client`) -- `GOG_KEYRING_PASSWORD=...` (used when keyring falls back to encrypted file backend in non-interactive environments) -- `GOG_KEYRING_BACKEND={auto|keychain|file}` (force backend; use `file` to avoid Keychain prompts and pair with `GOG_KEYRING_PASSWORD` for non-interactive) -- `GOG_KEYCHAIN_TRUST_APPLICATION={auto|true|false}` (control macOS Keychain application trust; auto enables it only for a stably signed binary) -- `GOG_KEYRING_SERVICE_NAME=...` (override keyring namespace/service name; default `gogcli`) -- `GOG_KEYRING_OPEN_TIMEOUT=30s` (max time to wait for a keyring open/operation — e.g. a macOS Keychain permission prompt — before failing; Go duration, default `30s` on macOS and `10s` elsewhere) -- `GOG_TIMEZONE=America/New_York` (default output timezone; IANA name or `UTC`; `local` forces local timezone) -- `GOG_ENABLE_COMMANDS=calendar,tasks,gmail.search` (optional prefix allowlist; dot paths allowed; parent paths allow children) -- `GOG_ENABLE_COMMANDS_EXACT=calendar.events,gmail.search` (optional exact allowlist; dot paths allowed; parent paths do not allow children) -- `GOG_DISABLE_COMMANDS=gmail.send,gmail.drafts.send` (optional denylist; dot paths allowed) -- `GOG_GMAIL_NO_SEND=1` (block Gmail send operations) -- `config.json` can also set `keyring_backend` (JSON5; env vars take precedence) -- `config.json` can also set `default_timezone` (IANA name or `UTC`) -- `config.json` can also set `places_api_key` (or use `GOG_PLACES_API_KEY` / `GOOGLE_PLACES_API_KEY`) for Calendar Places lookups. -- `config.json` can also set `account_aliases` for `gog auth alias` (JSON5) -- `config.json` can also set `account_clients` (email -> client) and `client_domains` (domain -> client) -- `config.json` can also set `gmail_no_send` and `no_send_accounts` for send guards - -Flag aliases: -- `--out` also accepts `--output`. -- `--out-dir` also accepts `--output-dir` (Gmail thread attachment downloads). -- Drive download/export commands accept `--out -` to write file bytes to stdout; `--json --out -` is rejected. - -## Commands (current + planned) - -### Implemented - -- `gog auth credentials ` -- `gog auth credentials list` -- `gog auth credentials remove [|all]` -- `gog --client auth credentials ` -- `gog auth add [--services user|all-user|all|gmail,calendar,chat,classroom,drive,driveactivity,drivelabels,docs,slides,contacts,tasks,sheets,people,forms,sites,meet,photos,photospicker,appscript,analytics,searchconsole,ads,youtube] [--readonly] [--drive-scope full|readonly|file] [--gmail-scope full|readonly|send|read-send] [--extra-scopes CSV] [--manual] [--remote] [--step 1|2] [--auth-url URL] [--listen-addr HOST[:PORT]] [--redirect-host HOST] [--timeout DURATION] [--force-consent]` -- `gog auth services [--markdown]` -- `gog auth manage [--services ...] [--listen-addr HOST[:PORT]] [--redirect-host HOST] [--dry-run]` (interactive browser flow; real execution fails with usage exit code 2 under `--no-input`) -- `gog auth keep --key ` (Google Keep; Workspace only) -- `gog auth list` -- `gog auth doctor [--check]` (diagnose missing OAuth client credentials, keyring/password drift, and refresh-token failures) -- `gog auth alias list` -- `gog auth alias set ` -- `gog auth alias unset ` -- `gog auth status` -- `gog auth remove ` -- `gog auth tokens list` -- `gog auth tokens delete ` -- `gog config get ` -- `gog config keys` -- `gog config list` -- `gog config path` -- `gog config set ` -- `gog config unset ` -- `gog version` -- `gog drive ls [--all] [--parent ID] [--max N] [--page TOKEN] [--query Q] [--[no-]all-drives]` (`--all` and `--parent` are mutually exclusive) -- `gog drive search [--raw-query] [--max N] [--page TOKEN] [--[no-]all-drives]` -- `gog drive get ` -- `gog drive download [--out PATH|-] [--format F]` (`--format` only applies to Google Workspace files; `--format md` exports a Google Doc as Markdown) -- `gog drive upload [--name N] [--parent ID] [--convert] [--convert-to doc|sheet|slides] [--keep-frontmatter]` (Markdown → Google Doc with `--convert` or `--convert-to doc`: leading `---`/`---` frontmatter is stripped before upload unless `--keep-frontmatter`; delimiter-based, not a full YAML parse; large non-JSON uploads print progress to stderr) -- `gog drive sync push --parent ID [--dry-run] [--[no-]all-drives]` (recursively reconciles local contents without deleting remote-only files; duplicate names, wrong types, Google-native files, and local symlinks fail before mutation) -- `gog drive mkdir [--parent ID]` -- `gog drive delete [--permanent]` -- `gog drive move --parent ID` -- `gog drive rename ` -- `gog drive shortcut create --parent ID [--name N]` -- `gog drive share --to anyone|user|domain [--email addr] [--domain example.com] [--role reader|writer|commenter] [--discoverable]` -- `gog drive permissions [--max N] [--page TOKEN]` -- `gog drive unshare ` -- `gog drive url ` -- `gog drive drives [--max N] [--page TOKEN] [--query Q]` -- `gog drive changes start-token [--drive DRIVE_ID]` -- `gog drive changes list --token TOKEN [--max N] [--all] [--drive DRIVE_ID]` -- `gog drive changes poll --state-file PATH [--interval DURATION] [--on-change COMMAND] [--filter-file FILE_ID] [--drive DRIVE_ID]` -- `gog drive changes serve --state-file PATH (--channel-token TOKEN|--channel-token-file PATH) [--listen ADDR] [--notification-timeout DURATION] [--on-change COMMAND] [--filter-file FILE_ID] [--auto-renew --webhook-url HTTPS_URL]` -- `gog drive changes watch --token TOKEN --webhook-url URL [--channel-id ID] [--channel-token TOKEN]` -- `gog drive changes stop ` -- `gog drive activity query [--file FILE_ID|--folder FOLDER_ID] [--actions edit,share] [--from RFC3339] [--to RFC3339] [--filter FILTER]` -- `gog drive audit sharing [--file FILE_ID|--parent FOLDER_ID] [--depth N] [--max N] [--internal-domain DOMAIN] [--public-only|--external-only] [--fail-found]` -- `gog drive audit user [--file FILE_ID|--parent FOLDER_ID] [--depth N] [--max N] [--fail-found]` -- `gog drive bulk remove-public [--file FILE_ID|--parent FOLDER_ID] [--depth N] [--dry-run] [--force]` -- `gog drive bulk update-role --from reader|commenter|writer --to reader|commenter|writer [--file FILE_ID|--parent FOLDER_ID] [--type user|group|domain|anyone] [--target EMAIL_OR_DOMAIN] [--dry-run] [--force]` -- `gog drive labels list [--max N] [--page TOKEN] [--customer CUSTOMERS_ID] [--published-only]` (requires a Google Workspace customer) -- `gog drive labels get [--view basic|full]` (requires a Google Workspace customer) -- `gog drive labels file list [--max N] [--page TOKEN]` -- `gog drive labels file apply [--text FIELD=VALUE] [--selection FIELD=CHOICE[,CHOICE]] [--integer FIELD=N] [--date FIELD=YYYY-MM-DD] [--user FIELD=email] [--unset FIELD] [--fields-json JSON]` -- `gog drive labels file remove ` - -Drive hierarchy semantics: - -- Files and folders are identified by stable opaque IDs, not paths. -- New files have one parent folder. The API still returns `parents` as an array - so legacy My Drive records with multiple parents can be read; `drive move` - removes every old parent and installs exactly the requested parent. -- An item visible from another folder is represented by a separate shortcut - file with its own ID, name, parent, and permissions. Shortcut metadata exposes - `shortcutDetails.targetId`, `targetMimeType`, and `targetResourceKey`. -- Mutations apply to the exact ID passed. Commands do not silently dereference - shortcut IDs to their targets. -- `drive tree`, `drive inventory`, and `drive du` treat shortcuts as leaves, - including shortcuts whose targets are folders. -- Tree and inventory output one row per discovered placement. Size summaries - aggregate each placement independently, even when legacy parent links expose - the same folder ID through multiple paths. -- Folder scans reject an ancestor cycle instead of following it indefinitely. - -- `gog slides thumbnail [--size small|medium|large] [--format png|jpeg] [--out PATH]` -- `gog slides element ...` (native page-element lifecycle; exact batch payloads available with `--dry-run --json`) -- `gog calendar calendars` -- `gog calendar subscribe ` -- `gog calendar unsubscribe ` -- `gog calendar create-calendar [--description D] [--timezone TZ] [--location L]` -- `gog calendar delete-calendar ` -- `gog calendar acl ` -- `gog calendar events [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--event-types TYPES] [--weekday]` - - `--event-types` filters to one or more event types (repeatable or comma-separated): `default`, `birthday`, `focus-time`, `from-gmail`, `out-of-office`, `working-location`. Unset returns all types (the API default). -- `gog calendar event|get ` -- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events` -- `gog calendar create --summary S --from DT --to DT [--timezone TZ] [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]` -- `gog calendar update [--summary S] [--from DT] [--to DT] [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees ...] [--add-attendee ...] [--attachment URL ...] [--all-day] [--with-meet|--regenerate-meet] [--event-type TYPE]` -- `gog calendar delete ` -- `gog calendar freebusy [calendarIds] [--cal ID_OR_NAME] [--calendars CSV] [--all] --from RFC3339 --to RFC3339` -- `gog calendar conflicts [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339|date|relative] [--to RFC3339|date|relative] [--today|--week|--days N]` -- `gog calendar respond --status accepted|declined|tentative [--send-updates all|none|externalOnly]` - -`calendar unsubscribe` removes only the selected entry from the caller's -calendar list. `calendar delete-calendar` permanently deletes an owned -secondary calendar; Google may briefly retain a stale calendar-list row after -the authoritative calendar resource is gone. - -Google Calendar appointment schedules are not exposed by the Calendar API, so -the CLI cannot list or manage them. - -- `gog maps places search [--language LANG] [--region REGION] [--fields FIELD_MASK] [--max N]` -- `gog maps places details [--language LANG] [--region REGION] [--fields FIELD_MASK]` -- `gog maps directions --origin ORIGIN --destination DESTINATION [--mode driving|walking|bicycling|transit] [--language LANG] [--region REGION]` -- `gog maps distance --origins CSV --destinations CSV [--mode driving|walking|bicycling|transit] [--units metric|imperial] [--language LANG] [--region REGION]` -- `gog maps geocode [--language LANG] [--region REGION]` -- `gog maps reverse-geocode --lat FLOAT --lng FLOAT [--language LANG] [--region REGION]` -- `gog photos list [--max N] [--page TOKEN]` -- `gog photos search [--album ALBUM_ID] [--media-type PHOTO|VIDEO|ALL_MEDIA] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--include-archived] [--max N] [--page TOKEN]` -- `gog photos get ` -- `gog photos download [--out PATH|-] [--video]` -- `gog photos picker create [--max-items N] [--open]` -- `gog photos picker get ` -- `gog photos picker wait [--timeout DURATION]` -- `gog photos picker list [--max N] [--page TOKEN] [--all]` -- `gog photos picker download [--out PATH|-] [--overwrite]` -- `gog photos picker delete ` -- `gog time now [--timezone TZ]` -- `gog classroom courses [--state ...] [--max N] [--page TOKEN]` -- `gog classroom courses get ` -- `gog classroom courses create --name NAME [--owner me] [--state ACTIVE|...]` -- `gog classroom courses update [--name ...] [--state ...]` -- `gog classroom courses delete ` -- `gog classroom courses archive ` -- `gog classroom courses unarchive ` -- `gog classroom courses join [--role student|teacher] [--user me]` -- `gog classroom courses leave [--role student|teacher] [--user me]` -- `gog classroom courses url ` - -Course state mutations wait for the requested state to become visible through -the Classroom API before returning success. If Google still serves stale state -after the bounded retry window, the command exits with retryable code `8`. - -- `gog classroom students [--max N] [--page TOKEN]` -- `gog classroom students get ` -- `gog classroom students add [--enrollment-code CODE]` -- `gog classroom students remove ` -- `gog classroom teachers [--max N] [--page TOKEN]` -- `gog classroom teachers get ` -- `gog classroom teachers add ` -- `gog classroom teachers remove ` -- `gog classroom roster [--students] [--teachers]` -- `gog classroom coursework [--state ...] [--topic TOPIC_ID] [--scan-pages N] [--max N] [--page TOKEN]` -- `gog classroom coursework get ` -- `gog classroom coursework create --title TITLE [--type ASSIGNMENT|...]` -- `gog classroom coursework update [--title ...]` -- `gog classroom coursework delete ` -- `gog classroom coursework assignees [--mode ...] [--add-student ...]` -- `gog classroom materials [--state ...] [--topic TOPIC_ID] [--scan-pages N] [--max N] [--page TOKEN]` -- `gog classroom materials get ` -- `gog classroom materials create --title TITLE` -- `gog classroom materials update [--title ...]` -- `gog classroom materials delete ` -- `gog classroom submissions [--state ...] [--max N] [--page TOKEN]` -- `gog classroom submissions get ` -- `gog classroom submissions turn-in ` -- `gog classroom submissions reclaim ` -- `gog classroom submissions return ` -- `gog classroom submissions grade [--draft N] [--assigned N]` -- `gog classroom announcements [--state ...] [--max N] [--page TOKEN]` -- `gog classroom announcements get ` -- `gog classroom announcements create --text TEXT` -- `gog classroom announcements update [--text ...]` -- `gog classroom announcements delete ` -- `gog classroom announcements assignees [--mode ...]` -- `gog classroom topics [--max N] [--page TOKEN]` -- `gog classroom topics get ` -- `gog classroom topics create --name NAME` -- `gog classroom topics update --name NAME` -- `gog classroom topics delete ` -- `gog classroom invitations [--course ID] [--user ID]` -- `gog classroom invitations get ` -- `gog classroom invitations create --role STUDENT|TEACHER|OWNER` -- `gog classroom invitations accept ` -- `gog classroom invitations delete ` -- `gog classroom guardians [--max N] [--page TOKEN]` -- `gog classroom guardians get ` -- `gog classroom guardians delete ` -- `gog classroom guardian-invitations [--state ...] [--max N] [--page TOKEN]` -- `gog classroom guardian-invitations get ` -- `gog classroom guardian-invitations create --email EMAIL` -- `gog classroom profile [userId]` -- `gog contacts dedupe [--match email,phone,name] [--max N] [--resource people/...] [--apply]` -- `gog gmail search [--max N] [--page TOKEN]` -- `gog gmail messages search [--max N] [--page TOKEN] [--include-body] [--body-format text|html] [--full]` -- `gog gmail autoreply [--max N] [--subject S] [--body B|--body-file PATH|--body-html HTML] [--from addr] [--reply-to addr] [--label L] [--archive] [--mark-read] [--skip-bulk] [--allow-self]` -- `gog gmail thread get [--download]` -- `gog gmail thread modify [--add ...] [--remove ...]` -- `gog gmail get [--format full|metadata|raw] [--headers ...]` -- `gog gmail attachment [--out PATH] [--name NAME] [--inline] [--inline-max-bytes BYTES]` -- `gog gmail url ` -- `gog gmail reply [--body B|--body-file PATH|--body-html HTML|--body-html-file PATH] [--to ...] [--cc ...] [--bcc ...] [--remove ...] [--subject S] [--no-quote] [--from addr|--auto-from-addressed-alias] [--signature|--signature-from addr|--signature-file path] [--attach ...]` -- `gog gmail reply-all [--body B|--body-file PATH|--body-html HTML|--body-html-file PATH] [--to ...] [--cc ...] [--bcc ...] [--remove ...] [--subject S] [--no-quote] [--from addr|--auto-from-addressed-alias] [--signature|--signature-from addr|--signature-file path] [--attach ...]` -- `gog gmail forward --to a@b.com [--cc ...] [--bcc ...] [--note TEXT|--note-file PATH] [--from addr] [--skip-attachments]` -- `gog gmail labels list` -- `gog gmail labels get ` -- `gog gmail labels create ` -- `gog gmail labels rename ` -- `gog gmail labels modify [--add ...] [--remove ...]` -- `gog gmail send --to a@b.com [--subject S] [--body B|--body-file PATH] [--body-html H|--body-html-file PATH] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach ...]` -- `gog gmail drafts list [--max N] [--page TOKEN]` -- `gog gmail drafts get [--download]` -- `gog gmail drafts create [--subject S] [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id |--thread-id ] [--reply-all] [--reply-to addr] [--from addr|--auto-from-addressed-alias] [--attach ...]` -- `gog gmail drafts update [--subject S] [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id |--thread-id ] [--reply-all] [--reply-to addr] [--from addr|--auto-from-addressed-alias] [--attach ...]` -- `gog gmail drafts reply [--body B|--body-file PATH|--body-html HTML|--body-html-file PATH] [--to ...] [--cc ...] [--bcc ...] [--remove ...] [--subject S] [--no-quote] [--from addr|--auto-from-addressed-alias] [--signature|--signature-from addr|--signature-file path] [--attach ...]` (same flags as `gmail reply`, but saves a draft instead of sending; works under no-send) -- `gog gmail drafts reply-all [--body B|--body-file PATH|--body-html HTML|--body-html-file PATH] [--to ...] [--cc ...] [--bcc ...] [--remove ...] [--subject S] [--no-quote] [--from addr|--auto-from-addressed-alias] [--signature|--signature-from addr|--signature-file path] [--attach ...]` (same flags as `gmail reply-all`, but saves a draft; works under no-send) -- `gog gmail drafts forward [--to a@b.com] [--cc ...] [--bcc ...] [--note TEXT|--note-file PATH] [--from addr] [--skip-attachments]` (same flags as `gmail forward`, but saves a draft; `--to` is optional for a draft; works under no-send) -- `gog gmail drafts send ` -- `gog gmail drafts delete ` -- `gog gmail watch start|status|renew|stop|serve` -- `gog gmail history --since ` -- `gog chat spaces list [--max N] [--page TOKEN]` -- `gog chat spaces find [--max N] [--exact]` -- `gog chat spaces create [--member email,...]` -- `gog chat messages list [--max N] [--page TOKEN] [--order ORDER] [--thread THREAD] [--unread]` - - JSON output includes user-mention annotations and emoji-reaction summaries when present; plain-text output is unchanged. -- `gog chat messages search [--max N] [--page TOKEN] [--all] [--order "create_time desc"|"relevance desc"] [--view basic|full] [--markup chat|markdown]` - - Searches messages available to the authenticated Workspace user across direct messages and spaces, subject to Google Chat API search exclusions such as muted spaces. The query uses Google Chat filter syntax and may include keywords, sender, space, date, mention, unread, link, or attachment filters. - - This is a read-only search, not an exhaustive export. `--max` accepts 1–100 per page; `--all` follows page tokens. `--order "relevance desc"` requires Google's Developer Preview access. - - JSON contains `results` and `nextPageToken`. Each result includes resource, space, thread, sender, text, and formatted text when available. `--wrap-untrusted` wraps externally supplied text and sender names without changing resource identifiers. - - `--view full` requests optional metadata: `read` needs `chat.users.readstate` or `chat.users.readstate.readonly`, and `spaceMuteSetting` needs `chat.users.spacesettings`. Missing read metadata is omitted from JSON and displayed as a blank READ cell, not `false`. Explicit `false` means unread. Basic view omits read state. - - Optional filters can require additional scopes; see [Google's search reference](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/search). The command does not expand an existing OAuth grant. - - Examples: `gog --readonly chat messages search 'project decision' --all --json` and `gog --readonly chat messages search 'project decision' --view full --markup markdown --wrap-untrusted --json`. -- `gog chat messages send --text TEXT [--thread THREAD]` -- `gog chat threads list [--max N] [--page TOKEN]` -- `gog chat dm space ` -- `gog chat dm send --text TEXT [--thread THREAD]` -- `gog tasks lists [--max N] [--page TOKEN]` -- `gog tasks lists create ` -- `gog tasks list <tasklistId> [--max N] [--page TOKEN]` -- `gog tasks get <tasklistId> <taskId>` -- `gog tasks add <tasklistId> --title T [--notes N] [--due RFC3339|YYYY-MM-DD] [--repeat daily|weekly|monthly|yearly] [--repeat-count N] [--repeat-until DT] [--parent ID] [--previous ID]` -- `gog tasks update <tasklistId> <taskId> [--title T] [--notes N] [--due RFC3339|YYYY-MM-DD] [--status needsAction|completed]` -- `gog tasks done <tasklistId> <taskId>` -- `gog tasks undo <tasklistId> <taskId>` -- `gog tasks delete <tasklistId> <taskId>` -- `gog tasks clear <tasklistId>` -- `gog contacts search <query> [--max N]` -- `gog contacts list [--max N] [--page TOKEN]` -- `gog contacts get <people/...|email>` -- `gog contacts export <people/...|email|name> [--out PATH|-]` -- `gog contacts export --query <query> [--max N] [--out PATH|-]` -- `gog contacts export --all [--page-size N] [--page TOKEN] [--out PATH|-]` -- `gog contacts create --given NAME [--family NAME] [--email addr] [--phone num] [--relation type=person]` -- `gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT] [--relation type=person] [--from-file PATH|-] [--ignore-etag]` -- `gog contacts delete <people/...>` -- `gog contacts directory list [--max N] [--page TOKEN]` -- `gog contacts directory search <query> [--max N] [--page TOKEN]` -- `gog contacts other list [--max N] [--page TOKEN]` -- `gog contacts other search <query> [--max N]` -- `gog people me` -- `gog people get <people/...|userId>` -- `gog people search <query> [--max N] [--page TOKEN]` -- `gog people relations [<people/...|userId>] [--type TYPE]` - -Date/time input conventions (shared parser): - -- Date-only: `YYYY-MM-DD` -- Datetime: `RFC3339` / `RFC3339Nano` / `YYYY-MM-DDTHH:MM[:SS]` / `YYYY-MM-DD HH:MM[:SS]` -- Numeric timezone offset accepted: `YYYY-MM-DDTHH:MM:SS-0800` -- Calendar range flags also accept relatives: `now`, `today`, `tomorrow`, `yesterday`, weekday names (`monday`, `next friday`) -- Tracking `--since` also accepts durations like `24h` - -### Planned high-level command tree - -- `gog auth …` - - `gog auth credentials <credentials.json>` - - `gog auth credentials list` - - `gog --client <name> auth credentials <credentials.json>` -- `gog gmail …` -- `gog chat …` -- `gog calendar …` -- `gog drive …` -- `gog contacts …` -- `gog tasks …` -- `gog people …` - -Planned service identifiers (canonical): - -- `gmail` -- `calendar` -- `chat` -- `drive` -- `contacts` -- `tasks` -- `people` - -## Google API dependencies (planned) - -- `golang.org/x/oauth2` -- `golang.org/x/oauth2/google` -- `google.golang.org/api/option` -- `google.golang.org/api/gmail/v1` -- `google.golang.org/api/calendar/v3` -- `google.golang.org/api/chat/v1` -- `google.golang.org/api/drive/v3` -- `google.golang.org/api/people/v1` -- `google.golang.org/api/tasks/v1` - -## Scopes (planned) - -We store a single refresh token per Google account email. - -- `gog auth add` requests a union of scopes based on `--services`. -- Each API client refreshes an access token for the subset of scopes needed for that service. -- If you later want additional services, re-run `gog auth add <email> --services ...` (may require `--force-consent` to mint a new refresh token). - -- Gmail: `https://mail.google.com/` (or narrower scopes if we decide later) -- Calendar: `https://www.googleapis.com/auth/calendar` -- Chat: - - `https://www.googleapis.com/auth/chat.spaces` - - `https://www.googleapis.com/auth/chat.messages` - - `https://www.googleapis.com/auth/chat.memberships` - - `https://www.googleapis.com/auth/chat.users.readstate.readonly` -- Drive: `https://www.googleapis.com/auth/drive` -- Drive Labels: `https://www.googleapis.com/auth/drive.labels.readonly` -- Contacts/Directory: - - `https://www.googleapis.com/auth/contacts` - - `https://www.googleapis.com/auth/contacts.other.readonly` - - `https://www.googleapis.com/auth/directory.readonly` -- People: - - `profile` (OIDC) -- YouTube: - - `https://www.googleapis.com/auth/youtube.readonly` for normal account reads - - `https://www.googleapis.com/auth/youtube.force-ssl` as an explicit extra scope for comments and mutations -- Photos: `https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata` -- Photos Picker: `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` (explicit opt-in) - -## Output formats - -Default: human-friendly tables (stdlib `text/tabwriter`). - -- Parseable stdout: - - `--json`: JSON objects/arrays suitable for scripting - - `--plain`: stable TSV (tabs preserved; no alignment; no colors) -- Human-facing hints/progress are written to stderr so stdout can be safely captured. -- Colors are only used for human-facing output and are disabled automatically for `--json` and `--plain`. - -We avoid heavy table deps unless we decide we need them. - -## Code layout (current) - -- `cmd/gog/main.go` — binary entrypoint -- `internal/cmd/*` — kong command structs -- `internal/ui/*` — color + printing -- `internal/config/*` — config paths + credential parsing/writing -- `internal/secrets/*` — keyring store - -## Formatting, linting, tests - -### Formatting - -Pinned tools, installed into local `.tools/` via `make tools`: - -- `mvdan.cc/gofumpt@v0.7.0` -- `golang.org/x/tools/cmd/goimports@v0.38.0` -- `github.com/golangci/golangci-lint/cmd/golangci-lint@v1.62.2` - -Commands: - -- `make fmt` — applies `goimports` + `gofumpt` -- `make fmt-check` — formats and fails if Go files or `go.mod/go.sum` change - -### Lint - -- `golangci-lint` with config in `.golangci.yml` -- `make lint` - -### Tests - -- stdlib `testing` (+ `httptest` when we add OAuth/API tests) -- `make test` - -### Integration tests (local only) - -There is an opt-in integration test suite guarded by build tags (not run in CI). - -- Requires: - - stored `credentials.json` (or `credentials-<client>.json`) via `gog auth credentials ...` - - refresh token in keyring via `gog auth add <email>` -- Run: - - `GOG_IT_ACCOUNT=you@gmail.com go test -tags=integration ./internal/integration` - - optional: `GOG_CLIENT=work` to select a non-default OAuth client - -## CI (GitHub Actions) - -Workflow: `.github/workflows/ci.yml` - -- runs on push + PR -- uses `actions/setup-go` with `go-version-file: go.mod` -- runs: - - `make tools` - - `make fmt-check` - - `go test ./...` - - `golangci-lint` (pinned `v1.62.2`) - -## Next implementation steps - -- Expand Gmail further (labels by name everywhere, richer body rendering, compose edge cases). -- Improve People updates (multi-field + richer contact data). -- Harden UX (consistent output formats, retries/backoff on specific transient errors). +Tests live beside the implementation. Google API behavior is normally covered +with `httptest`; [live testing](live-testing.md) describes the opt-in test-account +suite and resource cleanup. [Releasing](RELEASING.md) documents publication. diff --git a/go.mod b/go.mod index 73c8588e7..519bdbcc5 100644 --- a/go.mod +++ b/go.mod @@ -16,11 +16,11 @@ require ( github.com/yosuke-furukawa/json5 v0.1.1 github.com/yuin/goldmark v1.8.6 go.yaml.in/yaml/v3 v3.0.5 - golang.org/x/net v0.58.0 - golang.org/x/oauth2 v0.36.0 - golang.org/x/sys v0.47.0 - golang.org/x/term v0.45.0 - golang.org/x/text v0.41.0 + golang.org/x/net v0.59.0 + golang.org/x/oauth2 v0.37.0 + golang.org/x/sys v0.48.0 + golang.org/x/term v0.46.0 + golang.org/x/text v0.42.0 google.golang.org/api v0.297.0 ) @@ -35,14 +35,14 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect - github.com/dvsekhvalnov/jose2go v1.10.0 // indirect + github.com/dvsekhvalnov/jose2go v1.11.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.21 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect github.com/googleapis/gax-go/v2 v2.24.1 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect @@ -59,12 +59,12 @@ require ( go.opentelemetry.io/otel v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect - golang.org/x/crypto v0.56.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto v0.0.0-20260904194346-d0f1323225a4 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/genproto v0.0.0-20260908043556-f8649ddbbfe6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.12 // indirect ) diff --git a/go.sum b/go.sum index dcfb35ec1..d078e6c11 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dvsekhvalnov/jose2go v1.10.0 h1:5RmEnUoQBMBURnk346hX3dKqG60Jkf9qkp6dkLsFx60= -github.com/dvsekhvalnov/jose2go v1.10.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/dvsekhvalnov/jose2go v1.11.0 h1:9pnsV1/CLWFYSxMilqtiY1Fb4JZOxfDk/q0/CQiUMYw= +github.com/dvsekhvalnov/jose2go v1.11.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedur3D3z7kI6HiLX9g3TiAN4/DFU= -github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= +github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= github.com/googleapis/gax-go/v2 v2.24.1 h1:AtqTN21IXMMWo99LiEVAiBfNNQmO40d8xUfZI640mc0= github.com/googleapis/gax-go/v2 v2.24.1/go.mod h1:bWeBei0NVwaNZKb2y1HUBS7gLXIF3/Tu3pq7j8D2Tb0= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= @@ -171,8 +171,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -183,30 +183,30 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -224,12 +224,12 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20260904194346-d0f1323225a4 h1:6QRNo34FxJcE1LG6XfnmaQ17m6w21oXaKlKoBaF+UqE= -google.golang.org/genproto v0.0.0-20260904194346-d0f1323225a4/go.mod h1:eifJ91nefXnfP/bMkZsACVGm14ZtEL4eUXRLTryfbY4= -google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 h1:NCe/UiklGd/9xjT+ROBVhJ1kf6TRQaFedsR+z7u1gvo= -google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4/go.mod h1:fJ2lYaWjqNknJyQBOCd0fA3HnEElJqGplH71a2txi+g= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4 h1:5t+ZydAFj5kGVLrgCvLmpmCf9ylGRd64hpEronfRaws= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/genproto v0.0.0-20260908043556-f8649ddbbfe6 h1:OcDDEHV1vAlW4F6JleIWQbShOUNy2SD/cWDKH+or7uw= +google.golang.org/genproto v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:JfZ1Hyx5mAJgX/ip0vEicmPvzE6rIrbacB7rHMfjD7Q= +google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6 h1:O4Tjo2vlGeGM3+tNgwJrGj5fQHO2V71gDkFqbm328C8= +google.golang.org/genproto/googleapis/api v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:r4KD2hOq82JBWpTWkJ9NZLf6EwmRvAPIFmk7hPNtd+0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 h1:ieEbjQ6lzbvntOXUB9nMx9uH+yIU/HbgkNDjnk/mJuk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 077838ee3..755a08d90 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -275,6 +275,10 @@ func TestPushSnapshotCanReferenceExistingCheckpointShard(t *testing.T) { } func TestAsyncCheckpointPushDrainsBeforeFinalSnapshot(t *testing.T) { + // Keep maintenance attached so temporary-repo cleanup cannot race its writes. + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "maintenance.autoDetach") + t.Setenv("GIT_CONFIG_VALUE_0", "false") ctx := context.Background() dir := t.TempDir() repo := filepath.Join(dir, "repo") @@ -284,6 +288,10 @@ func TestAsyncCheckpointPushDrainsBeforeFinalSnapshot(t *testing.T) { if err := git(ctx, "", "init", "--bare", "--initial-branch=main", remote); err != nil { t.Fatalf("init remote: %v", err) } + // Local transport clears command-scope Git configuration for receive-pack. + if err := git(ctx, remote, "config", "maintenance.autoDetach", "false"); err != nil { + t.Fatalf("configure remote maintenance: %v", err) + } recipient, err := EnsureIdentity(identity) if err != nil { t.Fatalf("EnsureIdentity: %v", err) diff --git a/internal/cmd/VERSION b/internal/cmd/VERSION index f7fe8466c..9303ffa57 100644 --- a/internal/cmd/VERSION +++ b/internal/cmd/VERSION @@ -1 +1 @@ -v0.39.1-dev +v0.40.0-dev diff --git a/internal/cmd/api.go b/internal/cmd/api.go index ac95fc49c..429b1afd1 100644 --- a/internal/cmd/api.go +++ b/internal/cmd/api.go @@ -9,9 +9,11 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "unicode/utf8" + "github.com/openclaw/gogcli/internal/config" "github.com/openclaw/gogcli/internal/discoveryapi" "github.com/openclaw/gogcli/internal/googleapi" "github.com/openclaw/gogcli/internal/outfmt" @@ -35,6 +37,7 @@ type APIDescribeCmd struct { API string `arg:"" name:"api" help:"Discovery API name (for example gmail)"` Version string `arg:"" name:"version" help:"Discovery API version (for example v1)"` Method string `arg:"" optional:"" name:"method" help:"Optional Discovery method ID"` + NoCache bool `name:"no-cache" help:"Fetch the Discovery document without reading or writing the 24-hour disk cache"` } type APICallCmd struct { @@ -45,12 +48,23 @@ type APICallCmd struct { BodyJSON string `name:"body" help:"JSON request body or @file"` Scope string `name:"scope" help:"OAuth scope override (default: narrowest Discovery-listed scope)"` AllowWrite bool `name:"allow-write" help:"Allow non-read HTTP methods (also requires confirmation or --force)"` + NoCache bool `name:"no-cache" help:"Fetch the Discovery document without reading or writing the 24-hour disk cache"` } func discoveryClient() discoveryapi.Client { return discoveryapi.Client{BaseURL: os.Getenv("GOG_DISCOVERY_BASE_URL")} } +func discoveryDescriptionClient(ctx context.Context, noCache bool) discoveryapi.Client { + client := discoveryClient() + if !noCache { + if layout, err := commandLayout(ctx, config.PathKindCache); err == nil && layout.CacheDir != "" { + client.CacheDir = filepath.Join(layout.CacheDir, "discovery") + } + } + return client +} + func (c *APIListCmd) Run(ctx context.Context) error { raw, err := discoveryClient().List(ctx, !c.All) if err != nil { @@ -61,7 +75,7 @@ func (c *APIListCmd) Run(ctx context.Context) error { } func (c *APIDescribeCmd) Run(ctx context.Context) error { - description, err := discoveryClient().Description(ctx, c.API, c.Version) + description, err := discoveryDescriptionClient(ctx, c.NoCache).Description(ctx, c.API, c.Version) if err != nil { return err } @@ -81,7 +95,7 @@ func (c *APIDescribeCmd) Run(ctx context.Context) error { } func (c *APICallCmd) Run(ctx context.Context, flags *RootFlags) error { - description, err := discoveryClient().Description(ctx, c.API, c.Version) + description, err := discoveryDescriptionClient(ctx, c.NoCache).Description(ctx, c.API, c.Version) if err != nil { return err } diff --git a/internal/cmd/api_test.go b/internal/cmd/api_test.go index 5d9312cce..ea74a135b 100644 --- a/internal/cmd/api_test.go +++ b/internal/cmd/api_test.go @@ -6,9 +6,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "github.com/openclaw/gogcli/internal/app" @@ -48,6 +50,7 @@ func TestAPICallReadOnlyBlocksWriteBeforeAuth(t *testing.T) { } func TestAPICallReadOnlyAllowsQueryPOSTDryRun(t *testing.T) { + t.Setenv("GOG_CACHE_DIR", t.TempDir()) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `{"rootUrl":"https://www.googleapis.com/","servicePath":"calendar/v3/","resources":{"freebusy":{"methods":{"query":{"id":"calendar.freebusy.query","httpMethod":"POST","path":"freeBusy","scopes":["scope"]}}}}}`) @@ -246,3 +249,45 @@ func TestDiscoveryScopesSelectsNarrowestAlternative(t *testing.T) { t.Fatal("expected invalid override error") } } + +func TestDiscoveryCommandCacheAndBypass(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, _ = fmt.Fprint(w, `{"name":"drive","version":"v3","rootUrl":"https://www.googleapis.com/","servicePath":"drive/v3/","methods":{"list":{"id":"drive.files.list","httpMethod":"GET","path":"files","scopes":["https://www.googleapis.com/auth/drive.readonly"]}}}`) + })) + defer server.Close() + t.Setenv("GOG_DISCOVERY_BASE_URL", server.URL) + cache := t.TempDir() + ctx := withTestRuntime(newCmdRuntimeJSONOutputContext(t, io.Discard, io.Discard), func(r *app.Runtime) { r.Layout.CacheDir = cache }) + for range 2 { + if err := runKong(t, &APIDescribeCmd{}, []string{"drive", "v3"}, ctx, &RootFlags{}); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 1 { + t.Fatal("describe missed warm cache") + } + call := &APICallCmd{API: "drive", Version: "v3", Method: "drive.files.list"} + if err := call.Run(ctx, &RootFlags{DryRun: true}); err != nil && ExitCode(err) != 0 { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatal("call did not share description cache") + } + if err := call.Run(ctx, &RootFlags{DryRun: true, DisableCommands: "api.drive.files.list"}); err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatalf("cached command bypassed policy: %v", err) + } + for range 2 { + if err := runKong(t, &APIDescribeCmd{}, []string{"drive", "v3", "--no-cache"}, ctx, &RootFlags{}); err != nil { + t.Fatal(err) + } + } + call.NoCache = true + if err := call.Run(ctx, &RootFlags{DryRun: true}); err != nil && ExitCode(err) != 0 { + t.Fatal(err) + } + if calls.Load() != 4 { + t.Fatalf("bypass fetch count=%d, want 4", calls.Load()) + } +} diff --git a/internal/cmd/calendar_add_attendee_test.go b/internal/cmd/calendar_add_attendee_test.go index d6d64645a..32abf5f1d 100644 --- a/internal/cmd/calendar_add_attendee_test.go +++ b/internal/cmd/calendar_add_attendee_test.go @@ -55,9 +55,9 @@ func TestMergeAttendees(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := mergeAttendees(tt.existing, tt.addCSV) + got, _ := mergeAttendeesWithChange(tt.existing, tt.addCSV) if len(got) != tt.wantLen { - t.Errorf("mergeAttendees() returned %d attendees, want %d", len(got), tt.wantLen) + t.Errorf("mergeAttendeesWithChange() returned %d attendees, want %d", len(got), tt.wantLen) } if tt.name == "preserve existing metadata" && len(got) > 0 { @@ -85,7 +85,7 @@ func TestMergeAttendeesNewHaveNeedsAction(t *testing.T) { existing := []*calendar.EventAttendee{ {Email: "existing@test.com", ResponseStatus: "accepted"}, } - got := mergeAttendees(existing, "new@test.com") + got, _ := mergeAttendeesWithChange(existing, "new@test.com") for _, a := range got { if a.Email == "new@test.com" { @@ -99,7 +99,7 @@ func TestMergeAttendeesNewHaveNeedsAction(t *testing.T) { } func TestMergeAttendeesPreservesNewAttendeeModifiers(t *testing.T) { - got := mergeAttendees(nil, "room@resource.calendar.google.com;resource;optional;comment=Project room") + got, _ := mergeAttendeesWithChange(nil, "room@resource.calendar.google.com;resource;optional;comment=Project room") if len(got) != 1 { t.Fatalf("expected 1 attendee, got %d", len(got)) } diff --git a/internal/cmd/calendar_attendees.go b/internal/cmd/calendar_attendees.go index 694cdd97f..93613d905 100644 --- a/internal/cmd/calendar_attendees.go +++ b/internal/cmd/calendar_attendees.go @@ -21,13 +21,6 @@ func buildAttendees(csv string) []*calendar.EventAttendee { return out } -// mergeAttendees preserves existing attendees (with all their metadata like responseStatus) -// and adds new attendees from the CSV string. Duplicates (by email) are skipped. -func mergeAttendees(existing []*calendar.EventAttendee, addCSV string) []*calendar.EventAttendee { - out, _ := mergeAttendeesWithChange(existing, addCSV) - return out -} - // mergeAttendeesWithChange returns the merged attendees and whether at least one attendee was added. func mergeAttendeesWithChange(existing []*calendar.EventAttendee, addCSV string) ([]*calendar.EventAttendee, bool) { newAttendees := buildAttendees(addCSV) diff --git a/internal/cmd/docs_delete.go b/internal/cmd/docs_delete.go new file mode 100644 index 000000000..ee892af1f --- /dev/null +++ b/internal/cmd/docs_delete.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/alecthomas/kong" + "google.golang.org/api/docs/v1" + + "github.com/openclaw/gogcli/internal/docsedit" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +type DocsDeleteCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Start *int64 `name:"start" help:"Start index (>= 1; required unless --at is set)"` + End *int64 `name:"end" help:"End index (> start; required unless --at is set)"` + At string `name:"at" help:"Anchor by literal text and delete that matched range"` + Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"` + MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"` + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` + Segment string `name:"segment" help:"Target an exact header, footer, or footnote segment ID"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` + Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` +} + +func (c *DocsDeleteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := strings.TrimSpace(c.DocID) + if docID == "" { + return usage("empty docId") + } + placement, err := docsedit.PlanRangePlacement(docsedit.RangePlacementOptions{ + Start: c.Start, + End: c.End, + AllowZero: strings.TrimSpace(c.Segment) != "", + Anchor: docsedit.AnchorOptions{ + Text: c.At, + Provided: flagProvided(kctx, "at"), + Occurrence: c.Occurrence, + MatchCase: c.MatchCase, + }, + }) + if err != nil { + return usage(err.Error()) + } + at := placement.Anchor.Text + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + c.Tab = tab + dryRunPayload := map[string]any{ + "document_id": docID, + "start_index": docsDeleteDryRunStart(c.Start), + "end_index": docsDeleteDryRunEnd(c.End), + "deleted": docsDeleteDryRunDeleted(c.Start, c.End, at), + "tab": c.Tab, + "segment": c.Segment, + "batch": c.Batch, + } + addDocsAtAnchorDryRunPayload(dryRunPayload, docsAtAnchorFlags{At: at, Occurrence: c.Occurrence, MatchCase: c.MatchCase}) + if dryRunErr := dryRunExit(ctx, flags, "docs.delete", dryRunPayload); dryRunErr != nil { + return dryRunErr + } + if batchErr := validateDocsBatchTarget(ctx, flags, c.Batch, docID); batchErr != nil { + return batchErr + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, docID) + if err != nil { + return err + } + resolvedPlacement, err := resolveDocsPlacementTarget(ctx, svc, docID, c.Tab, c.Segment, placement) + if err != nil { + return err + } + start := resolvedPlacement.Range.Start + end := resolvedPlacement.Range.End + c.Tab = resolvedPlacement.TabID + c.Segment = resolvedPlacement.SegmentID + + batchReq := &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{docsedit.BuildDeleteRequest(docsedit.Range{Start: start, End: end}, c.Tab)}, + } + applyDocsRequestTarget(batchReq.Requests, docsTargetFromPlacement(resolvedPlacement.ResolvedPlacement)) + batchReq.WriteControl = docsRequiredRevisionWriteControl(resolvedPlacement.RequiredRevisionID) + if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, docID, "docs.delete", batchRevision, batchReq.Requests, false); queued || queueErr != nil { + return queueErr + } + result, err := svc.Documents.BatchUpdate(docID, batchReq).Context(ctx).Do() + if err != nil { + return fmt.Errorf("deleting content: %w", err) + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": result.DocumentId, + "deleted": end - start, + "startIndex": start, + "endIndex": end, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + if c.Segment != "" { + payload["segmentId"] = c.Segment + payload["segmentType"] = resolvedPlacement.SegmentKind + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("documentId\t%s", result.DocumentId) + u.Out().Linef("deleted\t%d characters", end-start) + u.Out().Linef("range\t%d-%d", start, end) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + if c.Segment != "" { + u.Out().Linef("segmentId\t%s", c.Segment) + u.Out().Linef("segmentType\t%s", resolvedPlacement.SegmentKind) + } + return nil +} + +func docsDeleteDryRunStart(start *int64) any { + if start == nil { + return nil + } + return *start +} + +func docsDeleteDryRunEnd(end *int64) any { + if end == nil { + return nil + } + return *end +} + +func docsDeleteDryRunDeleted(start, end *int64, at string) any { + if at != "" { + return "at:range" + } + if start == nil || end == nil { + return nil + } + return *end - *start +} + +type DocsClearCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` +} + +func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error { + docID := strings.TrimSpace(c.DocID) + if docID == "" { + return usage("empty docId") + } + if err := dryRunExit(ctx, flags, "docs.clear", map[string]any{ + "document_id": docID, + }); err != nil { + return err + } + return (&DocsSedCmd{DocID: docID, Expression: `s/^$//`}).Run(ctx, flags) +} diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 4d0b9ec51..d527cb64c 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -2,18 +2,8 @@ package cmd import ( "context" - "fmt" - "os" "strings" - "github.com/alecthomas/kong" - "google.golang.org/api/docs/v1" - "google.golang.org/api/drive/v3" - gapi "google.golang.org/api/googleapi" - - "github.com/openclaw/gogcli/internal/docsedit" - "github.com/openclaw/gogcli/internal/docsmarkdown" - "github.com/openclaw/gogcli/internal/outfmt" "github.com/openclaw/gogcli/internal/ui" ) @@ -34,1268 +24,6 @@ func resolveTabArg(ctx context.Context, tab, tabID string) (string, error) { return tab, nil } -type DocsWriteCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Text string `name:"text" help:"Text to write"` - File string `name:"file" help:"Text file path ('-' for stdin)"` - Replace bool `name:"replace" help:"Replace all content explicitly (required with --markdown unless --append is set)"` - Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace or --append)"` - Append bool `name:"append" help:"Append instead of replacing the document body"` - CheckOrphans bool `name:"check-orphans" help:"Block markdown replacement when open comment quotes would disappear"` - Pageless bool `name:"pageless" help:"Set document to pageless mode"` - Layout DocsLayoutFlags `embed:""` - Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` - TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` - Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` - Format DocsFormatFlags `embed:""` -} - -func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { - id := strings.TrimSpace(c.DocID) - if id == "" { - return usage("empty docId") - } - - text, err := c.resolveWriteText(ctx, kctx) - if err != nil { - return err - } - if c.Append && c.Replace { - return usage("--append cannot be combined with --replace") - } - if c.CheckOrphans && (!c.Markdown || !c.Replace || c.Append) { - return usage("--check-orphans requires --replace --markdown") - } - - tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) - if tabErr != nil { - return tabErr - } - if tab == "" && (flagProvided(kctx, "tab") || flagProvided(kctx, "tab-id")) { - return usage("--tab requires a non-empty tab title or ID") - } - c.Tab = tab - - if err := c.validateDocumentStyle(); err != nil { - return err - } - if c.Batch != "" && (c.Markdown || c.Pageless || c.Layout.any()) { - return usage("--batch supports plain text writes without --pageless or layout flags") - } - - if c.Markdown { - if c.Format.any() { - return usage("formatting flags are only supported for plain-text docs write; use markdown syntax or run docs format after writing") - } - return c.writeMarkdown(ctx, flags, id, text) - } - - return c.writePlainText(ctx, flags, id, text) -} - -func (c *DocsWriteCmd) validateDocumentStyle() error { - if !c.Pageless && !c.Layout.any() { - return nil - } - mode := "" - if c.Pageless { - mode = docsDocumentModePageless - } - _, err := buildUpdateDocumentStyleRequest(docsDocumentStyleOptions{ - Mode: mode, - DocsLayoutFlags: c.Layout, - }) - return err -} - -func (c *DocsWriteCmd) resolveWriteText(ctx context.Context, kctx *kong.Context) (string, error) { - text, provided, err := resolveTextInput(ctx, c.Text, c.File, kctx) - if err != nil { - return "", err - } - if !provided { - return "", usage("required: --text or --file") - } - if text == "" { - return "", usage("empty text") - } - return text, nil -} - -func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, docID, text string) error { - if c.Append && c.Format.createsBullets() && c.Format.hasParagraphStyle() { - return usage("docs write --append cannot combine bullet creation with paragraph formatting; append first, then use docs format") - } - if c.Format.any() { - if _, err := c.Format.buildRequests(1, 1+utf16Len(text), c.Tab); err != nil { - return err - } - } - - dryRunPayload := map[string]any{ - "document_id": docID, - "written": len(text), - "append": c.Append, - "replace": !c.Append, - "markdown": false, - "pageless": c.Pageless, - "tab": c.Tab, - "batch": c.Batch, - } - for k, v := range c.Layout.dryRunPayload() { - dryRunPayload[k] = v - } - if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { - return err - } - if err := validateDocsBatchTarget(ctx, flags, c.Batch, docID); err != nil { - return err - } - - svc, err := requireDocsService(ctx, flags) - if err != nil { - return err - } - batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, docID) - if err != nil { - return err - } - - endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) - if err != nil { - return err - } - c.Tab = tabID - insertIndex := int64(1) - if c.Append { - insertIndex = docsedit.AppendIndex(endIndex) - } - - reqs, err := docsedit.BuildWriteRequests(docsedit.WriteOptions{ - EndIndex: endIndex, - InsertIndex: insertIndex, - Text: text, - TabID: c.Tab, - Append: c.Append, - Format: c.Format.options(), - }) - if err != nil { - return usage(err.Error()) - } - if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, docID, "docs.write", batchRevision, reqs, !c.Append); queued || queueErr != nil { - return queueErr - } - resp, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{Requests: reqs}).Context(ctx).Do() - if err != nil { - if isDocsNotFound(err) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) - } - return err - } - if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { - return err - } - - return c.writePlainTextResult(ctx, resp, len(reqs), insertIndex) -} - -func (c *DocsWriteCmd) applyDocumentStyle(ctx context.Context, svc *docs.Service, docID string) error { - if !c.Pageless && !c.Layout.any() { - return nil - } - mode := "" - if c.Pageless { - mode = docsDocumentModePageless - } - // Document-style fields are per-tab. Resolve --tab to a concrete tab ID so - // pageless/layout lands on the targeted tab rather than silently hitting the - // default tab. resolveDocsTabID is a no-op when the tab is already a concrete - // ID (as in the plain-text path) and skipped entirely when no tab was given. - tabID := "" - if tab := strings.TrimSpace(c.Tab); tab != "" { - resolved, err := resolveDocsTabID(ctx, svc, docID, tab) - if err != nil { - return fmt.Errorf("resolve tab %q: %w", tab, err) - } - tabID = resolved - } - if err := setDocumentStyle(ctx, svc, docID, docsDocumentStyleOptions{ - Mode: mode, - TabID: tabID, - DocsLayoutFlags: c.Layout, - }); err != nil { - return fmt.Errorf("set document style: %w", err) - } - return nil -} - -func (c *DocsWriteCmd) writePlainTextResult(ctx context.Context, resp *docs.BatchUpdateDocumentResponse, requestCount int, insertIndex int64) error { - u := ui.FromContext(ctx) - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": resp.DocumentId, - "requests": requestCount, - "append": c.Append, - "index": insertIndex, - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - for k, v := range c.Layout.dryRunPayload() { - payload[k] = v - } - if resp.WriteControl != nil { - payload["writeControl"] = resp.WriteControl - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("id\t%s", resp.DocumentId) - u.Out().Linef("requests\t%d", requestCount) - u.Out().Linef("append\t%t", c.Append) - u.Out().Linef("index\t%d", insertIndex) - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { - u.Out().Linef("revision\t%s", resp.WriteControl.RequiredRevisionId) - } - return nil -} - -func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error { - markdown := prepareMarkdown(content) - plan, err := docsedit.PlanMarkdownWrite(docsedit.MarkdownWriteOptions{ - Markdown: markdown.cleaned, - ImageCount: len(markdown.images), - Append: c.Append, - Replace: c.Replace, - Tab: c.Tab, - CheckOrphans: c.CheckOrphans, - ApplyDocumentStyle: c.Pageless || c.Layout.any(), - }) - if err != nil { - return usage(err.Error()) - } - - switch plan.Mode { - case docsedit.MarkdownWriteDriveReplace: - return c.replaceMarkdownWithDrive(ctx, flags, docID, markdown, plan) - case docsedit.MarkdownWriteLocalAppend: - return c.appendMarkdown(ctx, flags, docID, markdown, plan) - case docsedit.MarkdownWriteLocalReplace: - return c.replaceMarkdownLocally(ctx, flags, docID, markdown, plan) - default: - return fmt.Errorf("unsupported markdown write mode: %d", plan.Mode) - } -} - -func (c *DocsWriteCmd) replaceMarkdownWithDrive( - ctx context.Context, - flags *RootFlags, - docID string, - markdown preparedMarkdown, - plan docsedit.MarkdownWritePlan, -) error { - u := ui.FromContext(ctx) - dryRunPayload := map[string]any{ - "document_id": docID, - "written": len(markdown.source), - "append": false, - "replace": true, - "markdown": true, - "pageless": c.Pageless, - "images": plan.ImageCount, - "check_orphans": plan.CheckOrphans, - } - for k, v := range c.Layout.dryRunPayload() { - dryRunPayload[k] = v - } - if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { - return err - } - - account, driveSvc, err := requireDriveService(ctx, flags) - if err != nil { - return err - } - - var docsSvc *docs.Service - if plan.CheckOrphans { - docsSvc, err = docsService(ctx, account) - if err != nil { - return err - } - orphans, tabID, orphanErr := findDocsWriteMarkdownOrphans( - ctx, - driveSvc, - docsSvc, - docID, - markdown, - plan.Tab, - plan.OrphanScopeWholeDocument, - ) - if orphanErr != nil { - return orphanErr - } - if resultErr := writeDocsWriteOrphanResult(ctx, docID, tabID, orphans); resultErr != nil { - return resultErr - } - } - - updated, err := driveSvc.Files.Update(docID, &drive.File{}). - Media(strings.NewReader(plan.Markdown), gapi.ContentType(mimeTextMarkdown)). - SupportsAllDrives(true). - Fields("id,name,webViewLink"). - Context(ctx). - Do() - if err != nil { - return fmt.Errorf("writing markdown to document: %w", err) - } - - if plan.RequiresDocumentsService && docsSvc == nil { - var svcErr error - docsSvc, svcErr = docsService(ctx, account) - if svcErr != nil { - return svcErr - } - } - rewrittenHeadingLinks := 0 - if plan.RewriteHeadingLinks { - count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, docsSvc, docID, plan.Tab, plan.ExplicitHeadingAnchors) - if rewriteErr != nil { - return fmt.Errorf("rewrite heading links: %w", rewriteErr) - } - rewrittenHeadingLinks = count - } - if plan.InsertImages { - if err := insertImagesIntoDocs(ctx, docsSvc, docID, markdown.images, plan.Tab); err != nil { - cleanupDocsImagePlaceholders(ctx, docsSvc, docID, markdown.images, plan.Tab) - return fmt.Errorf("insert images: %w", err) - } - } - if plan.ApplyDocumentStyle { - if err := c.applyDocumentStyle(ctx, docsSvc, docID); err != nil { - return err - } - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": updated.Id, - "written": len(markdown.source), - "replaced": true, - "markdown": true, - } - if c.Pageless { - payload["pageless"] = true - } - if rewrittenHeadingLinks > 0 { - payload["headingLinks"] = rewrittenHeadingLinks - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("documentId\t%s", updated.Id) - u.Out().Linef("written\t%d", len(markdown.source)) - u.Out().Linef("mode\treplaced (markdown converted)") - if c.Pageless { - u.Out().Linef("pageless\ttrue") - } - if rewrittenHeadingLinks > 0 { - u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) - } - if updated.WebViewLink != "" { - u.Out().Linef("link\t%s", updated.WebViewLink) - } - return nil -} - -func (c *DocsWriteCmd) appendMarkdown( - ctx context.Context, - flags *RootFlags, - docID string, - markdown preparedMarkdown, - plan docsedit.MarkdownWritePlan, -) error { - dryRunPayload := map[string]any{ - "document_id": docID, - "written": len(plan.Markdown), - "append": true, - "replace": false, - "markdown": true, - "pageless": c.Pageless, - "tab": plan.Tab, - "images": plan.ImageCount, - } - for k, v := range c.Layout.dryRunPayload() { - dryRunPayload[k] = v - } - if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { - return err - } - - svc, err := requireDocsService(ctx, flags) - if err != nil { - return err - } - - endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) - if err != nil { - return err - } - c.Tab = tabID - insertIndex := docsedit.AppendIndex(endIndex) - insertResult, err := insertPreparedDocsMarkdownAt(ctx, svc, docID, insertIndex, markdown, c.Tab, true) - if err != nil { - if isDocsNotFound(err) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) - } - return err - } - if plan.ApplyDocumentStyle { - if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { - return err - } - } - rewrittenHeadingLinks := 0 - if plan.RewriteHeadingLinks { - count, rewriteErr := rewriteMarkdownHeadingLinksFromIndex( - ctx, - svc, - docID, - c.Tab, - plan.ExplicitHeadingAnchors, - insertResult.ContentStart, - ) - if rewriteErr != nil { - return fmt.Errorf("rewrite heading links: %w", rewriteErr) - } - rewrittenHeadingLinks = count - insertResult.RequestCount += count - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": docID, - "written": insertResult.Inserted, - "requests": insertResult.RequestCount, - "append": true, - "index": insertIndex, - "markdown": true, - } - if c.Pageless { - payload["pageless"] = true - } - if rewrittenHeadingLinks > 0 { - payload["headingLinks"] = rewrittenHeadingLinks - } - for k, v := range c.Layout.dryRunPayload() { - payload[k] = v - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u := ui.FromContext(ctx) - u.Out().Linef("documentId\t%s", docID) - u.Out().Linef("written\t%d", insertResult.Inserted) - u.Out().Linef("requests\t%d", insertResult.RequestCount) - u.Out().Linef("mode\tappended (markdown converted)") - u.Out().Linef("index\t%d", insertIndex) - if rewrittenHeadingLinks > 0 { - u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) - } - if c.Pageless { - u.Out().Linef("pageless\ttrue") - } - return nil -} - -// replaceMarkdownLocally renders Markdown through Docs batchUpdate after -// clearing the selected body. This preserves tab targeting and table-cell -// line breaks that Drive's whole-document converter cannot represent. -func (c *DocsWriteCmd) replaceMarkdownLocally( - ctx context.Context, - flags *RootFlags, - docID string, - markdown preparedMarkdown, - plan docsedit.MarkdownWritePlan, -) error { - dryRunPayload := map[string]any{ - "document_id": docID, - "written": len(plan.Markdown), - "append": false, - "replace": true, - "markdown": true, - "pageless": c.Pageless, - "tab": plan.Tab, - "images": plan.ImageCount, - "check_orphans": plan.CheckOrphans, - } - for k, v := range c.Layout.dryRunPayload() { - dryRunPayload[k] = v - } - if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { - return err - } - - var svc *docs.Service - var err error - if plan.CheckOrphans { - account, driveSvc, driveErr := requireDriveService(ctx, flags) - if driveErr != nil { - return driveErr - } - svc, err = docsService(ctx, account) - if err != nil { - return err - } - orphans, resolvedTabID, orphanErr := findDocsWriteMarkdownOrphans( - ctx, - driveSvc, - svc, - docID, - markdown, - plan.Tab, - plan.OrphanScopeWholeDocument, - ) - if orphanErr != nil { - return orphanErr - } - if resultErr := writeDocsWriteOrphanResult(ctx, docID, resolvedTabID, orphans); resultErr != nil { - return resultErr - } - c.Tab = resolvedTabID - } else { - svc, err = requireDocsService(ctx, flags) - } - if err != nil { - return err - } - - endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) - if err != nil { - return err - } - c.Tab = tabID - - // Wipe existing tab body (everything between the implicit start index 1 - // and the last segment endIndex - 1). Skipped when the tab is already - // empty (endIndex <= 2 means a single newline segment). - deleteEnd := endIndex - 1 - if deleteEnd > 1 { - if _, derr := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ - Requests: []*docs.Request{{ - DeleteContentRange: &docs.DeleteContentRangeRequest{ - Range: &docs.Range{StartIndex: 1, EndIndex: deleteEnd, TabId: tabID}, - }, - }}, - }).Context(ctx).Do(); derr != nil { - if isDocsNotFound(derr) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) - } - return fmt.Errorf("clear tab content: %w", derr) - } - } - - insertResult, err := insertPreparedDocsMarkdownAt(ctx, svc, docID, 1, markdown, tabID, true) - if err != nil { - if isDocsNotFound(err) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) - } - return err - } - if plan.ApplyDocumentStyle { - if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { - return err - } - } - rewrittenHeadingLinks := 0 - if plan.RewriteHeadingLinks { - count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, svc, docID, tabID, plan.ExplicitHeadingAnchors) - if rewriteErr != nil { - return fmt.Errorf("rewrite heading links: %w", rewriteErr) - } - rewrittenHeadingLinks = count - insertResult.RequestCount += count - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": docID, - "written": insertResult.Inserted, - "requests": insertResult.RequestCount, - "replaced": true, - "markdown": true, - "tabId": tabID, - } - if c.Pageless { - payload["pageless"] = true - } - if rewrittenHeadingLinks > 0 { - payload["headingLinks"] = rewrittenHeadingLinks - } - for k, v := range c.Layout.dryRunPayload() { - payload[k] = v - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u := ui.FromContext(ctx) - u.Out().Linef("documentId\t%s", docID) - u.Out().Linef("written\t%d", insertResult.Inserted) - u.Out().Linef("requests\t%d", insertResult.RequestCount) - u.Out().Linef("mode\treplaced tab (markdown converted)") - u.Out().Linef("tabId\t%s", tabID) - if rewrittenHeadingLinks > 0 { - u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) - } - if c.Pageless { - u.Out().Linef("pageless\ttrue") - } - return nil -} - -type DocsUpdateCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Text string `name:"text" help:"Text to insert"` - File string `name:"file" help:"Text file path ('-' for stdin)"` - Index int64 `name:"index" help:"Insert index (default: end of document)"` - ReplaceRange string `name:"replace-range" help:"Replace UTF-16 Docs API range START:END instead of inserting"` - At string `name:"at" help:"Anchor by literal text and replace that matched range"` - Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"` - MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"` - Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting"` - Pageless bool `name:"pageless" help:"Set document to pageless mode"` - Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` - Segment string `name:"segment" help:"Target an exact header, footer, or footnote segment ID"` - TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` - Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` -} - -func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) - id := strings.TrimSpace(c.DocID) - if id == "" { - return usage("empty docId") - } - - text, provided, err := resolveTextInput(ctx, c.Text, c.File, kctx) - if err != nil { - return err - } - if !provided { - return usage("required: --text or --file") - } - if text == "" { - return usage("empty text") - } - placement, err := docsedit.PlanUpdatePlacement(docsedit.UpdatePlacementOptions{ - Index: c.Index, - IndexProvided: flagProvided(kctx, "index"), - AllowZero: strings.TrimSpace(c.Segment) != "", - ReplaceRange: c.ReplaceRange, - Anchor: docsedit.AnchorOptions{ - Text: c.At, - Provided: flagProvided(kctx, "at"), - Occurrence: c.Occurrence, - MatchCase: c.MatchCase, - }, - }) - if err != nil { - return usage(err.Error()) - } - at := placement.Anchor.Text - replacing := placement.Kind == docsedit.PlacementRange - replaceRange := placement.Range - replaceStart, replaceEnd := replaceRange.Start, replaceRange.End - - tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) - if tabErr != nil { - return tabErr - } - c.Tab = tab - if strings.TrimSpace(c.Segment) != "" && c.Markdown { - return usage("--segment supports plain-text updates only; markdown can contain structures that are invalid in segments") - } - if c.Batch != "" && (c.Markdown || c.Pageless) { - return usage("--batch supports plain text updates without --pageless") - } - if dryRunErr := dryRunExit(ctx, flags, "docs.update", c.dryRunPayload(id, len(text), replacing, replaceStart, replaceEnd, at)); dryRunErr != nil { - return dryRunErr - } - if batchErr := validateDocsBatchTarget(ctx, flags, c.Batch, id); batchErr != nil { - return batchErr - } - - svc, err := requireDocsService(ctx, flags) - if err != nil { - return err - } - batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, id) - if err != nil { - return err - } - - resolvedPlacement, err := resolveDocsPlacementTarget(ctx, svc, id, c.Tab, c.Segment, placement) - if err != nil { - return err - } - insertIndex := resolvedPlacement.Index - c.Tab = resolvedPlacement.TabID - c.Segment = resolvedPlacement.SegmentID - if resolvedPlacement.Range != nil { - replaceStart = resolvedPlacement.Range.Start - replaceEnd = resolvedPlacement.Range.End - replacing = true - } - - requestCount := 0 - written := len(text) - var resp *docs.BatchUpdateDocumentResponse - - if c.Markdown { - var inserted int - markdown := prepareMarkdown(text) - explicitHeadingAnchors := docsmarkdown.ExplicitHeadingAnchors(markdown.cleaned) - if replacing { - loadedDoc := resolvedPlacement.Document - if loadedDoc == nil { - loaded, loadErr := loadDocsTargetDocument(ctx, svc, id, c.Tab) - if loadErr != nil { - return loadErr - } - c.Tab = loaded.tabID - loadedDoc = loaded.full - } - replacedRequests, replacedText, replaceErr := replacePreparedDocsMarkdownRange( - ctx, - svc, - loadedDoc, - replaceStart, - replaceEnd, - markdown, - c.Tab, - ) - if replaceErr != nil { - err = replaceErr - } else { - inserted = replacedText - requestCount = replacedRequests - } - } else { - var insertResult docsMarkdownInsertResult - insertResult, err = insertPreparedDocsMarkdownAt(ctx, svc, id, insertIndex, markdown, c.Tab, true) - requestCount = insertResult.RequestCount - inserted = insertResult.Inserted - if err == nil && markdownMayContainHeadingLinks(markdown.cleaned) { - var rewritten int - rewritten, err = rewriteMarkdownHeadingLinksInRange( - ctx, - svc, - id, - c.Tab, - explicitHeadingAnchors, - insertResult.ContentStart, - insertResult.ContentEnd, - ) - requestCount += rewritten - } - } - if err != nil { - if isDocsNotFound(err) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) - } - return err - } - written = inserted - resp = &docs.BatchUpdateDocumentResponse{DocumentId: id} - } else { - var targetRange *docsedit.Range - if replacing { - targetRange = &docsedit.Range{Start: replaceStart, End: replaceEnd} - } - reqs := docsedit.BuildUpdateRequests(text, insertIndex, c.Tab, targetRange) - applyDocsRequestTarget(reqs, docsTargetFromPlacement(resolvedPlacement.ResolvedPlacement)) - requestCount = len(reqs) - batchReq := &docs.BatchUpdateDocumentRequest{Requests: reqs} - batchReq.WriteControl = docsRequiredRevisionWriteControl(resolvedPlacement.RequiredRevisionID) - if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, id, "docs.update", batchRevision, reqs, false); queued || queueErr != nil { - return queueErr - } - resp, err = svc.Documents.BatchUpdate(id, batchReq).Context(ctx).Do() - if err != nil { - if isDocsNotFound(err) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) - } - return err - } - } - if c.Pageless { - if err := setDocumentPageless(ctx, svc, id); err != nil { - return fmt.Errorf("set pageless mode: %w", err) - } - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": resp.DocumentId, - "requests": requestCount, - "index": insertIndex, - } - if replacing { - payload["replaced"] = true - payload["replaceRange"] = map[string]int64{"start": replaceStart, "end": replaceEnd} - } - if c.Markdown { - payload["written"] = written - payload["markdown"] = true - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - if c.Segment != "" { - payload["segmentId"] = c.Segment - payload["segmentType"] = resolvedPlacement.SegmentKind - } - if resp.WriteControl != nil { - payload["writeControl"] = resp.WriteControl - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("id\t%s", resp.DocumentId) - u.Out().Linef("requests\t%d", requestCount) - u.Out().Linef("index\t%d", insertIndex) - if replacing { - u.Out().Linef("replaced\ttrue") - u.Out().Linef("range\t%d:%d", replaceStart, replaceEnd) - } - if c.Markdown { - u.Out().Linef("written\t%d", written) - u.Out().Linef("markdown\ttrue") - } - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - if c.Segment != "" { - u.Out().Linef("segmentId\t%s", c.Segment) - u.Out().Linef("segmentType\t%s", resolvedPlacement.SegmentKind) - } - if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { - u.Out().Linef("revision\t%s", resp.WriteControl.RequiredRevisionId) - } - return nil -} - -func (c *DocsUpdateCmd) dryRunPayload(docID string, written int, replacing bool, replaceStart, replaceEnd int64, at string) map[string]any { - var index any = docsAtIndexEnd - switch { - case replacing: - index = replaceStart - case at != "": - index = docsAtIndexAnchorStart - case c.Index > 0: - index = c.Index - } - payload := map[string]any{ - "document_id": docID, - "written": written, - "index": index, - "markdown": c.Markdown, - "pageless": c.Pageless, - "tab": c.Tab, - "segment": c.Segment, - "batch": c.Batch, - } - if replacing { - payload["replaceRange"] = map[string]int64{"start": replaceStart, "end": replaceEnd} - } - addDocsAtAnchorDryRunPayload(payload, docsAtAnchorFlags{At: at, Occurrence: c.Occurrence, MatchCase: c.MatchCase}) - return payload -} - -type DocsInsertCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Content string `arg:"" optional:"" name:"content" help:"Text to insert (or use --file / stdin)"` - Index *int64 `name:"index" help:"Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted."` - At string `name:"at" help:"Anchor by literal text and insert at the start of the matched range"` - Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"` - MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"` - File string `name:"file" short:"f" help:"Read content from file (use - for stdin)"` - Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting before inserting"` - Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` - Segment string `name:"segment" help:"Target an exact header, footer, or footnote segment ID"` - TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` - Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` -} - -func (c *DocsInsertCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) - docID := strings.TrimSpace(c.DocID) - if docID == "" { - return usage("empty docId") - } - content, err := resolveContentInput(ctx, c.Content, c.File) - if err != nil { - return err - } - if content == "" { - return usage("no content provided (use argument, --file, or stdin)") - } - placement, err := docsedit.PlanInsertPlacement(docsedit.InsertPlacementOptions{ - Index: c.Index, - AllowZero: strings.TrimSpace(c.Segment) != "", - Anchor: docsedit.AnchorOptions{ - Text: c.At, - Provided: flagProvided(kctx, "at"), - Occurrence: c.Occurrence, - MatchCase: c.MatchCase, - }, - }) - if err != nil { - return usage(err.Error()) - } - at := placement.Anchor.Text - - tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) - if tabErr != nil { - return tabErr - } - c.Tab = tab - if strings.TrimSpace(c.Segment) != "" && c.Markdown { - return usage("--segment supports plain-text inserts only; markdown can contain structures that are invalid in segments") - } - if c.Markdown && c.Batch != "" { - return usage("--markdown cannot be combined with --batch") - } - dryRunPayload := map[string]any{ - "documentId": docID, - "inserted": len(content), - "markdown": c.Markdown, - "tab": c.Tab, - "segment": c.Segment, - "batch": c.Batch, - } - switch placement.Kind { - case docsedit.PlacementAnchor: - dryRunPayload["atIndex"] = docsAtIndexAnchorStart - addDocsAtAnchorDryRunPayload(dryRunPayload, docsAtAnchorFlags{At: at, Occurrence: c.Occurrence, MatchCase: c.MatchCase}) - case docsedit.PlacementIndex: - dryRunPayload["atIndex"] = placement.Index - default: - dryRunPayload["atIndex"] = docsAtIndexEnd - } - if dryRunErr := dryRunExit(ctx, flags, "docs.insert", dryRunPayload); dryRunErr != nil { - return dryRunErr - } - if batchErr := validateDocsBatchTarget(ctx, flags, c.Batch, docID); batchErr != nil { - return batchErr - } - - svc, err := requireDocsService(ctx, flags) - if err != nil { - return err - } - batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, docID) - if err != nil { - return err - } - - resolvedPlacement, err := resolveDocsPlacementTarget(ctx, svc, docID, c.Tab, c.Segment, placement) - if err != nil { - return err - } - insertIndex := resolvedPlacement.Index - c.Tab = resolvedPlacement.TabID - c.Segment = resolvedPlacement.SegmentID - - if c.Markdown { - return c.runMarkdown(ctx, svc, docID, insertIndex, resolvedPlacement.RequiredRevisionID, content) - } - - batchReq := &docs.BatchUpdateDocumentRequest{ - Requests: []*docs.Request{docsedit.BuildInsertRequest(content, insertIndex, c.Tab)}, - } - applyDocsRequestTarget(batchReq.Requests, docsTargetFromPlacement(resolvedPlacement.ResolvedPlacement)) - batchReq.WriteControl = docsRequiredRevisionWriteControl(resolvedPlacement.RequiredRevisionID) - if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, docID, "docs.insert", batchRevision, batchReq.Requests, false); queued || queueErr != nil { - return queueErr - } - result, err := svc.Documents.BatchUpdate(docID, batchReq).Context(ctx).Do() - if err != nil { - return fmt.Errorf("inserting text: %w", err) - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{"documentId": result.DocumentId, "inserted": len(content), "atIndex": insertIndex} - if c.Tab != "" { - payload["tabId"] = c.Tab - } - if c.Segment != "" { - payload["segmentId"] = c.Segment - payload["segmentType"] = resolvedPlacement.SegmentKind - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("documentId\t%s", result.DocumentId) - u.Out().Linef("inserted\t%d bytes", len(content)) - u.Out().Linef("atIndex\t%d", insertIndex) - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - if c.Segment != "" { - u.Out().Linef("segmentId\t%s", c.Segment) - u.Out().Linef("segmentType\t%s", resolvedPlacement.SegmentKind) - } - return nil -} - -// runMarkdown converts the supplied content from markdown to Google Docs -// formatting and inserts it at insertIndex. It reuses the same converter + -// insertion helper that backs `docs write --markdown` and the non-replacing -// branch of `docs update --markdown`, so headings, fenced code blocks, lists, -// tables and images render identically regardless of which command placed them. -func (c *DocsInsertCmd) runMarkdown( - ctx context.Context, - svc *docs.Service, - docID string, - insertIndex int64, - requiredRevisionID string, - content string, -) error { - markdown := prepareMarkdown(content) - insertResult, err := insertPreparedDocsMarkdownAtWithWriteControl( - ctx, - svc, - docID, - insertIndex, - markdown, - c.Tab, - true, - docsRequiredRevisionWriteControl(requiredRevisionID), - ) - if err != nil { - if isDocsNotFound(err) { - return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) - } - return err - } - requestCount := insertResult.RequestCount - if markdownMayContainHeadingLinks(markdown.cleaned) { - explicitHeadingAnchors := docsmarkdown.ExplicitHeadingAnchors(markdown.cleaned) - rewritten, rewriteErr := rewriteMarkdownHeadingLinksInRange( - ctx, - svc, - docID, - c.Tab, - explicitHeadingAnchors, - insertResult.ContentStart, - insertResult.ContentEnd, - ) - if rewriteErr != nil { - return fmt.Errorf("rewrite heading links: %w", rewriteErr) - } - requestCount += rewritten - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": docID, - "inserted": insertResult.Inserted, - "requests": requestCount, - "atIndex": insertIndex, - "markdown": true, - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u := ui.FromContext(ctx) - u.Out().Linef("documentId\t%s", docID) - u.Out().Linef("inserted\t%d", insertResult.Inserted) - u.Out().Linef("requests\t%d", requestCount) - u.Out().Linef("atIndex\t%d", insertIndex) - u.Out().Linef("markdown\ttrue") - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - return nil -} - -type DocsDeleteCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Start *int64 `name:"start" help:"Start index (>= 1; required unless --at is set)"` - End *int64 `name:"end" help:"End index (> start; required unless --at is set)"` - At string `name:"at" help:"Anchor by literal text and delete that matched range"` - Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"` - MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"` - Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` - Segment string `name:"segment" help:"Target an exact header, footer, or footnote segment ID"` - TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` - Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` -} - -func (c *DocsDeleteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) - docID := strings.TrimSpace(c.DocID) - if docID == "" { - return usage("empty docId") - } - placement, err := docsedit.PlanRangePlacement(docsedit.RangePlacementOptions{ - Start: c.Start, - End: c.End, - AllowZero: strings.TrimSpace(c.Segment) != "", - Anchor: docsedit.AnchorOptions{ - Text: c.At, - Provided: flagProvided(kctx, "at"), - Occurrence: c.Occurrence, - MatchCase: c.MatchCase, - }, - }) - if err != nil { - return usage(err.Error()) - } - at := placement.Anchor.Text - - tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) - if tabErr != nil { - return tabErr - } - c.Tab = tab - dryRunPayload := map[string]any{ - "document_id": docID, - "start_index": docsDeleteDryRunStart(c.Start), - "end_index": docsDeleteDryRunEnd(c.End), - "deleted": docsDeleteDryRunDeleted(c.Start, c.End, at), - "tab": c.Tab, - "segment": c.Segment, - "batch": c.Batch, - } - addDocsAtAnchorDryRunPayload(dryRunPayload, docsAtAnchorFlags{At: at, Occurrence: c.Occurrence, MatchCase: c.MatchCase}) - if dryRunErr := dryRunExit(ctx, flags, "docs.delete", dryRunPayload); dryRunErr != nil { - return dryRunErr - } - if batchErr := validateDocsBatchTarget(ctx, flags, c.Batch, docID); batchErr != nil { - return batchErr - } - - svc, err := requireDocsService(ctx, flags) - if err != nil { - return err - } - batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, docID) - if err != nil { - return err - } - resolvedPlacement, err := resolveDocsPlacementTarget(ctx, svc, docID, c.Tab, c.Segment, placement) - if err != nil { - return err - } - start := resolvedPlacement.Range.Start - end := resolvedPlacement.Range.End - c.Tab = resolvedPlacement.TabID - c.Segment = resolvedPlacement.SegmentID - - batchReq := &docs.BatchUpdateDocumentRequest{ - Requests: []*docs.Request{docsedit.BuildDeleteRequest(docsedit.Range{Start: start, End: end}, c.Tab)}, - } - applyDocsRequestTarget(batchReq.Requests, docsTargetFromPlacement(resolvedPlacement.ResolvedPlacement)) - batchReq.WriteControl = docsRequiredRevisionWriteControl(resolvedPlacement.RequiredRevisionID) - if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, docID, "docs.delete", batchRevision, batchReq.Requests, false); queued || queueErr != nil { - return queueErr - } - result, err := svc.Documents.BatchUpdate(docID, batchReq).Context(ctx).Do() - if err != nil { - return fmt.Errorf("deleting content: %w", err) - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": result.DocumentId, - "deleted": end - start, - "startIndex": start, - "endIndex": end, - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - if c.Segment != "" { - payload["segmentId"] = c.Segment - payload["segmentType"] = resolvedPlacement.SegmentKind - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("documentId\t%s", result.DocumentId) - u.Out().Linef("deleted\t%d characters", end-start) - u.Out().Linef("range\t%d-%d", start, end) - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - if c.Segment != "" { - u.Out().Linef("segmentId\t%s", c.Segment) - u.Out().Linef("segmentType\t%s", resolvedPlacement.SegmentKind) - } - return nil -} - -func docsDeleteDryRunStart(start *int64) any { - if start == nil { - return nil - } - return *start -} - -func docsDeleteDryRunEnd(end *int64) any { - if end == nil { - return nil - } - return *end -} - -func docsDeleteDryRunDeleted(start, end *int64, at string) any { - if at != "" { - return "at:range" - } - if start == nil || end == nil { - return nil - } - return *end - *start -} - -type DocsClearCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` -} - -func (c *DocsClearCmd) Run(ctx context.Context, flags *RootFlags) error { - docID := strings.TrimSpace(c.DocID) - if docID == "" { - return usage("empty docId") - } - if err := dryRunExit(ctx, flags, "docs.clear", map[string]any{ - "document_id": docID, - }); err != nil { - return err - } - return (&DocsSedCmd{DocID: docID, Expression: `s/^$//`}).Run(ctx, flags) -} - -type DocsFindReplaceCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Find string `arg:"" name:"find" help:"Text to find"` - ReplaceText string `arg:"" optional:"" name:"replace" help:"Replacement text (omit when using --content-file)"` - ContentFile string `name:"content-file" help:"Read replacement from a file instead of the positional argument."` - MatchCase bool `name:"match-case" help:"Case-sensitive matching"` - Format string `name:"format" help:"Replacement format: plain|markdown. Markdown converts formatting, tables, and inline images from public HTTPS URLs." default:"plain" enum:"plain,markdown"` - First bool `name:"first" help:"Replace only the first occurrence instead of all."` - Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` - TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` -} - type DocsEditCmd struct { DocID string `arg:"" name:"docId" help:"Doc ID"` Find string `arg:"" name:"find" help:"Text to find"` @@ -1311,251 +39,3 @@ func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error { MatchCase: c.MatchCase, }).Run(ctx, flags) } - -func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) - docID := strings.TrimSpace(c.DocID) - if docID == "" { - return usage("empty docId") - } - if c.Find == "" { - return usage("find text cannot be empty") - } - - replaceText, err := c.resolveReplaceText() - if err != nil { - return err - } - - format := strings.ToLower(strings.TrimSpace(c.Format)) - if format == "" { - format = docsContentFormatPlain - } - - tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) - if tabErr != nil { - return tabErr - } - c.Tab = tab - - if dryRunErr := dryRunExit(ctx, flags, "docs.find-replace", map[string]any{ - "document_id": docID, - "find": c.Find, - "replace": replaceText, - "format": format, - "first": c.First, - "match_case": c.MatchCase, - "tab": c.Tab, - }); dryRunErr != nil { - return dryRunErr - } - - svc, err := requireDocsService(ctx, flags) - if err != nil { - return err - } - - if c.Tab != "" { - tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) - if tabErr != nil { - return tabErr - } - c.Tab = tabID - } - - if flags != nil && flags.DryRun { - return c.runDryRun(ctx, u, svc, docID, replaceText, format) - } - - if !c.First && format == docsContentFormatPlain { - return c.runReplaceAll(ctx, u, svc, docID, replaceText) - } - - loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab) - if err != nil { - return err - } - c.Tab = loaded.tabID - doc := loaded.full - targetDoc := loaded.target - - if c.First { - matches := docsedit.FindTextRanges(targetDoc, c.Find, docsedit.SearchOptions{ - MatchCase: c.MatchCase, - PreserveHTMLEntities: true, - RequireTextSegment: true, - }) - if len(matches) == 0 { - return c.printFirstResult(ctx, u, docID, replaceText, 0, 0) - } - match := matches[0] - if format == docsContentFormatMarkdown { - err = c.runMarkdown(ctx, svc, doc, match.StartIndex, match.EndIndex, replaceText) - } else { - err = c.runPlain(ctx, svc, doc, match.StartIndex, match.EndIndex, replaceText) - } - if err != nil { - return err - } - return c.printFirstResult(ctx, u, docID, replaceText, 1, len(matches)) - } - - matches := docsedit.FindTextRanges(targetDoc, c.Find, docsedit.SearchOptions{ - MatchCase: c.MatchCase, - PreserveHTMLEntities: true, - RequireTextSegment: true, - }) - for i := len(matches) - 1; i >= 0; i-- { - if err = c.runMarkdown(ctx, svc, doc, matches[i].StartIndex, matches[i].EndIndex, replaceText); err != nil { - return err - } - if i == 0 { - continue - } - loaded, err = loadDocsTargetDocument(ctx, svc, docID, c.Tab) - if err != nil { - return fmt.Errorf("re-reading document: %w", err) - } - doc = loaded.full - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": docID, - "find": c.Find, - "replace": replaceText, - "replacements": len(matches), - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("documentId\t%s", docID) - u.Out().Linef("find\t%s", c.Find) - u.Out().Linef("replace\t%s", replaceText) - u.Out().Linef("replacements\t%d", len(matches)) - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - return nil -} - -func (c *DocsFindReplaceCmd) runDryRun(ctx context.Context, u *ui.UI, svc *docs.Service, docID, replaceText, format string) error { - loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab) - if err != nil { - return err - } - c.Tab = loaded.tabID - - matches := docsedit.FindTextRanges(loaded.target, c.Find, docsedit.SearchOptions{ - MatchCase: c.MatchCase, - PreserveHTMLEntities: true, - RequireTextSegment: true, - }) - replacements := len(matches) - if c.First && replacements > 1 { - replacements = 1 - } - remaining := len(matches) - replacements - - payload := map[string]any{ - "documentId": docID, - "find": c.Find, - "replace": replaceText, - "format": format, - "first": c.First, - "replacements": replacements, - "remaining": remaining, - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - if err := dryRunExit(ctx, &RootFlags{DryRun: true}, "docs.find-replace", payload); err != nil { - return err - } - if !outfmt.IsJSON(ctx) { - u.Out().Linef("matches\t%d", len(matches)) - } - return nil -} - -func (c *DocsFindReplaceCmd) runReplaceAll(ctx context.Context, u *ui.UI, svc *docs.Service, docID, replaceText string) error { - documentID, replacements, err := runDocsReplaceAll(ctx, svc, docID, c.Find, replaceText, c.MatchCase, c.Tab) - if err != nil { - return err - } - - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": documentID, - "find": c.Find, - "replace": replaceText, - "replacements": replacements, - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("documentId\t%s", documentID) - u.Out().Linef("find\t%s", c.Find) - u.Out().Linef("replace\t%s", replaceText) - u.Out().Linef("replacements\t%d", replacements) - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - return nil -} - -func (c *DocsFindReplaceCmd) runPlain(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string) error { - return replaceDocsTextRange(ctx, svc, doc, startIdx, endIdx, replaceText, c.Tab) -} - -func (c *DocsFindReplaceCmd) runMarkdown(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string) error { - _, _, err := replaceDocsMarkdownRange(ctx, svc, doc, startIdx, endIdx, replaceText, c.Tab) - return err -} - -func (c *DocsFindReplaceCmd) printFirstResult(ctx context.Context, u *ui.UI, docID, replaceText string, replacements, total int) error { - if outfmt.IsJSON(ctx) { - payload := map[string]any{ - "documentId": docID, - "find": c.Find, - "replacements": replacements, - "remaining": total - replacements, - } - if c.Tab != "" { - payload["tabId"] = c.Tab - } - return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) - } - - u.Out().Linef("documentId\t%s", docID) - u.Out().Linef("find\t%s", c.Find) - u.Out().Linef("replace\t%s", replaceText) - u.Out().Linef("replacements\t%d", replacements) - if remaining := total - replacements; remaining > 0 { - u.Out().Linef("remaining\t%d", remaining) - } - if c.Tab != "" { - u.Out().Linef("tabId\t%s", c.Tab) - } - return nil -} - -func (c *DocsFindReplaceCmd) resolveReplaceText() (string, error) { - if c.ContentFile != "" && c.ReplaceText != "" { - return "", usage("cannot use both replace argument and --content-file") - } - if c.ContentFile == "" { - return c.ReplaceText, nil - } - data, err := os.ReadFile(c.ContentFile) - if err != nil { - return "", fmt.Errorf("read content file: %w", err) - } - return string(data), nil -} diff --git a/internal/cmd/docs_find_replace.go b/internal/cmd/docs_find_replace.go new file mode 100644 index 000000000..264e4cac1 --- /dev/null +++ b/internal/cmd/docs_find_replace.go @@ -0,0 +1,274 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/docs/v1" + + "github.com/openclaw/gogcli/internal/docsedit" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +type DocsFindReplaceCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Find string `arg:"" name:"find" help:"Text to find"` + ReplaceText string `arg:"" optional:"" name:"replace" help:"Replacement text (omit when using --content-file)"` + ContentFile string `name:"content-file" help:"Read replacement from a file instead of the positional argument."` + MatchCase bool `name:"match-case" help:"Case-sensitive matching"` + Format string `name:"format" help:"Replacement format: plain|markdown. Markdown converts formatting, tables, and inline images from public HTTPS URLs." default:"plain" enum:"plain,markdown"` + First bool `name:"first" help:"Replace only the first occurrence instead of all."` + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` +} + +func (c *DocsFindReplaceCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := strings.TrimSpace(c.DocID) + if docID == "" { + return usage("empty docId") + } + if c.Find == "" { + return usage("find text cannot be empty") + } + + replaceText, err := c.resolveReplaceText() + if err != nil { + return err + } + + format := strings.ToLower(strings.TrimSpace(c.Format)) + if format == "" { + format = docsContentFormatPlain + } + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + c.Tab = tab + + if dryRunErr := dryRunExit(ctx, flags, "docs.find-replace", map[string]any{ + "document_id": docID, + "find": c.Find, + "replace": replaceText, + "format": format, + "first": c.First, + "match_case": c.MatchCase, + "tab": c.Tab, + }); dryRunErr != nil { + return dryRunErr + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + if c.Tab != "" { + tabID, tabErr := resolveDocsTabID(ctx, svc, docID, c.Tab) + if tabErr != nil { + return tabErr + } + c.Tab = tabID + } + + if flags != nil && flags.DryRun { + return c.runDryRun(ctx, u, svc, docID, replaceText, format) + } + + if !c.First && format == docsContentFormatPlain { + return c.runReplaceAll(ctx, u, svc, docID, replaceText) + } + + loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab) + if err != nil { + return err + } + c.Tab = loaded.tabID + doc := loaded.full + targetDoc := loaded.target + + if c.First { + matches := docsedit.FindTextRanges(targetDoc, c.Find, docsedit.SearchOptions{ + MatchCase: c.MatchCase, + PreserveHTMLEntities: true, + RequireTextSegment: true, + }) + if len(matches) == 0 { + return c.printFirstResult(ctx, u, docID, replaceText, 0, 0) + } + match := matches[0] + if format == docsContentFormatMarkdown { + err = c.runMarkdown(ctx, svc, doc, match.StartIndex, match.EndIndex, replaceText) + } else { + err = c.runPlain(ctx, svc, doc, match.StartIndex, match.EndIndex, replaceText) + } + if err != nil { + return err + } + return c.printFirstResult(ctx, u, docID, replaceText, 1, len(matches)) + } + + matches := docsedit.FindTextRanges(targetDoc, c.Find, docsedit.SearchOptions{ + MatchCase: c.MatchCase, + PreserveHTMLEntities: true, + RequireTextSegment: true, + }) + for i := len(matches) - 1; i >= 0; i-- { + if err = c.runMarkdown(ctx, svc, doc, matches[i].StartIndex, matches[i].EndIndex, replaceText); err != nil { + return err + } + if i == 0 { + continue + } + loaded, err = loadDocsTargetDocument(ctx, svc, docID, c.Tab) + if err != nil { + return fmt.Errorf("re-reading document: %w", err) + } + doc = loaded.full + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "find": c.Find, + "replace": replaceText, + "replacements": len(matches), + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("documentId\t%s", docID) + u.Out().Linef("find\t%s", c.Find) + u.Out().Linef("replace\t%s", replaceText) + u.Out().Linef("replacements\t%d", len(matches)) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + return nil +} + +func (c *DocsFindReplaceCmd) runDryRun(ctx context.Context, u *ui.UI, svc *docs.Service, docID, replaceText, format string) error { + loaded, err := loadDocsTargetDocument(ctx, svc, docID, c.Tab) + if err != nil { + return err + } + c.Tab = loaded.tabID + + matches := docsedit.FindTextRanges(loaded.target, c.Find, docsedit.SearchOptions{ + MatchCase: c.MatchCase, + PreserveHTMLEntities: true, + RequireTextSegment: true, + }) + replacements := len(matches) + if c.First && replacements > 1 { + replacements = 1 + } + remaining := len(matches) - replacements + + payload := map[string]any{ + "documentId": docID, + "find": c.Find, + "replace": replaceText, + "format": format, + "first": c.First, + "replacements": replacements, + "remaining": remaining, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + if err := dryRunExit(ctx, &RootFlags{DryRun: true}, "docs.find-replace", payload); err != nil { + return err + } + if !outfmt.IsJSON(ctx) { + u.Out().Linef("matches\t%d", len(matches)) + } + return nil +} + +func (c *DocsFindReplaceCmd) runReplaceAll(ctx context.Context, u *ui.UI, svc *docs.Service, docID, replaceText string) error { + documentID, replacements, err := runDocsReplaceAll(ctx, svc, docID, c.Find, replaceText, c.MatchCase, c.Tab) + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": documentID, + "find": c.Find, + "replace": replaceText, + "replacements": replacements, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("documentId\t%s", documentID) + u.Out().Linef("find\t%s", c.Find) + u.Out().Linef("replace\t%s", replaceText) + u.Out().Linef("replacements\t%d", replacements) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + return nil +} + +func (c *DocsFindReplaceCmd) runPlain(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string) error { + return replaceDocsTextRange(ctx, svc, doc, startIdx, endIdx, replaceText, c.Tab) +} + +func (c *DocsFindReplaceCmd) runMarkdown(ctx context.Context, svc *docs.Service, doc *docs.Document, startIdx, endIdx int64, replaceText string) error { + _, _, err := replaceDocsMarkdownRange(ctx, svc, doc, startIdx, endIdx, replaceText, c.Tab) + return err +} + +func (c *DocsFindReplaceCmd) printFirstResult(ctx context.Context, u *ui.UI, docID, replaceText string, replacements, total int) error { + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "find": c.Find, + "replacements": replacements, + "remaining": total - replacements, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("documentId\t%s", docID) + u.Out().Linef("find\t%s", c.Find) + u.Out().Linef("replace\t%s", replaceText) + u.Out().Linef("replacements\t%d", replacements) + if remaining := total - replacements; remaining > 0 { + u.Out().Linef("remaining\t%d", remaining) + } + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + return nil +} + +func (c *DocsFindReplaceCmd) resolveReplaceText() (string, error) { + if c.ContentFile != "" && c.ReplaceText != "" { + return "", usage("cannot use both replace argument and --content-file") + } + if c.ContentFile == "" { + return c.ReplaceText, nil + } + data, err := os.ReadFile(c.ContentFile) + if err != nil { + return "", fmt.Errorf("read content file: %w", err) + } + return string(data), nil +} diff --git a/internal/cmd/docs_insert.go b/internal/cmd/docs_insert.go new file mode 100644 index 000000000..6ee28c0ba --- /dev/null +++ b/internal/cmd/docs_insert.go @@ -0,0 +1,226 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/alecthomas/kong" + "google.golang.org/api/docs/v1" + + "github.com/openclaw/gogcli/internal/docsedit" + "github.com/openclaw/gogcli/internal/docsmarkdown" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +type DocsInsertCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Content string `arg:"" optional:"" name:"content" help:"Text to insert (or use --file / stdin)"` + Index *int64 `name:"index" help:"Character index to insert at (1 = beginning). Defaults to end-of-doc when omitted."` + At string `name:"at" help:"Anchor by literal text and insert at the start of the matched range"` + Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"` + MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"` + File string `name:"file" short:"f" help:"Read content from file (use - for stdin)"` + Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting before inserting"` + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` + Segment string `name:"segment" help:"Target an exact header, footer, or footnote segment ID"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` + Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` +} + +func (c *DocsInsertCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + docID := strings.TrimSpace(c.DocID) + if docID == "" { + return usage("empty docId") + } + content, err := resolveContentInput(ctx, c.Content, c.File) + if err != nil { + return err + } + if content == "" { + return usage("no content provided (use argument, --file, or stdin)") + } + placement, err := docsedit.PlanInsertPlacement(docsedit.InsertPlacementOptions{ + Index: c.Index, + AllowZero: strings.TrimSpace(c.Segment) != "", + Anchor: docsedit.AnchorOptions{ + Text: c.At, + Provided: flagProvided(kctx, "at"), + Occurrence: c.Occurrence, + MatchCase: c.MatchCase, + }, + }) + if err != nil { + return usage(err.Error()) + } + at := placement.Anchor.Text + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + c.Tab = tab + if strings.TrimSpace(c.Segment) != "" && c.Markdown { + return usage("--segment supports plain-text inserts only; markdown can contain structures that are invalid in segments") + } + if c.Markdown && c.Batch != "" { + return usage("--markdown cannot be combined with --batch") + } + dryRunPayload := map[string]any{ + "documentId": docID, + "inserted": len(content), + "markdown": c.Markdown, + "tab": c.Tab, + "segment": c.Segment, + "batch": c.Batch, + } + switch placement.Kind { + case docsedit.PlacementAnchor: + dryRunPayload["atIndex"] = docsAtIndexAnchorStart + addDocsAtAnchorDryRunPayload(dryRunPayload, docsAtAnchorFlags{At: at, Occurrence: c.Occurrence, MatchCase: c.MatchCase}) + case docsedit.PlacementIndex: + dryRunPayload["atIndex"] = placement.Index + default: + dryRunPayload["atIndex"] = docsAtIndexEnd + } + if dryRunErr := dryRunExit(ctx, flags, "docs.insert", dryRunPayload); dryRunErr != nil { + return dryRunErr + } + if batchErr := validateDocsBatchTarget(ctx, flags, c.Batch, docID); batchErr != nil { + return batchErr + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, docID) + if err != nil { + return err + } + + resolvedPlacement, err := resolveDocsPlacementTarget(ctx, svc, docID, c.Tab, c.Segment, placement) + if err != nil { + return err + } + insertIndex := resolvedPlacement.Index + c.Tab = resolvedPlacement.TabID + c.Segment = resolvedPlacement.SegmentID + + if c.Markdown { + return c.runMarkdown(ctx, svc, docID, insertIndex, resolvedPlacement.RequiredRevisionID, content) + } + + batchReq := &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{docsedit.BuildInsertRequest(content, insertIndex, c.Tab)}, + } + applyDocsRequestTarget(batchReq.Requests, docsTargetFromPlacement(resolvedPlacement.ResolvedPlacement)) + batchReq.WriteControl = docsRequiredRevisionWriteControl(resolvedPlacement.RequiredRevisionID) + if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, docID, "docs.insert", batchRevision, batchReq.Requests, false); queued || queueErr != nil { + return queueErr + } + result, err := svc.Documents.BatchUpdate(docID, batchReq).Context(ctx).Do() + if err != nil { + return fmt.Errorf("inserting text: %w", err) + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{"documentId": result.DocumentId, "inserted": len(content), "atIndex": insertIndex} + if c.Tab != "" { + payload["tabId"] = c.Tab + } + if c.Segment != "" { + payload["segmentId"] = c.Segment + payload["segmentType"] = resolvedPlacement.SegmentKind + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("documentId\t%s", result.DocumentId) + u.Out().Linef("inserted\t%d bytes", len(content)) + u.Out().Linef("atIndex\t%d", insertIndex) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + if c.Segment != "" { + u.Out().Linef("segmentId\t%s", c.Segment) + u.Out().Linef("segmentType\t%s", resolvedPlacement.SegmentKind) + } + return nil +} + +// runMarkdown converts the supplied content from markdown to Google Docs +// formatting and inserts it at insertIndex. It reuses the same converter + +// insertion helper that backs `docs write --markdown` and the non-replacing +// branch of `docs update --markdown`, so headings, fenced code blocks, lists, +// tables and images render identically regardless of which command placed them. +func (c *DocsInsertCmd) runMarkdown( + ctx context.Context, + svc *docs.Service, + docID string, + insertIndex int64, + requiredRevisionID string, + content string, +) error { + markdown := prepareMarkdown(content) + insertResult, err := insertPreparedDocsMarkdownAtWithWriteControl( + ctx, + svc, + docID, + insertIndex, + markdown, + c.Tab, + true, + docsRequiredRevisionWriteControl(requiredRevisionID), + ) + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + requestCount := insertResult.RequestCount + if markdownMayContainHeadingLinks(markdown.cleaned) { + explicitHeadingAnchors := docsmarkdown.ExplicitHeadingAnchors(markdown.cleaned) + rewritten, rewriteErr := rewriteMarkdownHeadingLinksInRange( + ctx, + svc, + docID, + c.Tab, + explicitHeadingAnchors, + insertResult.ContentStart, + insertResult.ContentEnd, + ) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + requestCount += rewritten + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "inserted": insertResult.Inserted, + "requests": requestCount, + "atIndex": insertIndex, + "markdown": true, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u := ui.FromContext(ctx) + u.Out().Linef("documentId\t%s", docID) + u.Out().Linef("inserted\t%d", insertResult.Inserted) + u.Out().Linef("requests\t%d", requestCount) + u.Out().Linef("atIndex\t%d", insertIndex) + u.Out().Linef("markdown\ttrue") + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + return nil +} diff --git a/internal/cmd/docs_sed_brace_format.go b/internal/cmd/docs_sed_brace_format.go index d58ee662b..816d95a7b 100644 --- a/internal/cmd/docs_sed_brace_format.go +++ b/internal/cmd/docs_sed_brace_format.go @@ -286,11 +286,6 @@ func buildBraceBreakRequests(be *braceExpr, insertIdx int64) []*docs.Request { return requests } -// hasBraceTextFormat checks if braceExpr has formatting that requires text styling. -func hasBraceTextFormat(be *braceExpr) bool { - return braceExprHasTextFormat(be) -} - // hasBraceParagraphFormat checks if braceExpr has formatting that requires paragraph styling. func hasBraceParagraphFormat(be *braceExpr) bool { if be == nil { @@ -299,5 +294,3 @@ func hasBraceParagraphFormat(be *braceExpr) bool { return be.Heading != "" || be.Align != "" || be.Indent >= 0 || be.Leading > 0 || be.SpacingSet } - -// (Legacy attrsTobraceExpr removed — sedAttrs no longer exists) diff --git a/internal/cmd/docs_sed_brace_format_test.go b/internal/cmd/docs_sed_brace_format_test.go index 35c381e8d..4e354b422 100644 --- a/internal/cmd/docs_sed_brace_format_test.go +++ b/internal/cmd/docs_sed_brace_format_test.go @@ -539,7 +539,7 @@ func TestHasBraceTextFormat(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := hasBraceTextFormat(tt.expr) + got := braceExprHasTextFormat(tt.expr) assert.Equal(t, tt.want, got) }) } diff --git a/internal/cmd/docs_sed_brace_resolve.go b/internal/cmd/docs_sed_brace_resolve.go index 63b5decf1..6e6fc3adb 100644 --- a/internal/cmd/docs_sed_brace_resolve.go +++ b/internal/cmd/docs_sed_brace_resolve.go @@ -24,10 +24,6 @@ func resolveHeading(h string) string { if mapped, ok := headingMap[h]; ok { return mapped } - // Check for numeric string - if len(h) == 1 && h[0] >= '1' && h[0] <= '6' { - return fmt.Sprintf("HEADING_%s", h) - } return h } diff --git a/internal/cmd/docs_sed_brace_structural.go b/internal/cmd/docs_sed_brace_structural.go index 27348ed4e..536f3e188 100644 --- a/internal/cmd/docs_sed_brace_structural.go +++ b/internal/cmd/docs_sed_brace_structural.go @@ -1,4 +1,3 @@ -// Package cmd provides CLI commands for Google Docs operations. package cmd import ( @@ -67,41 +66,6 @@ func buildCheckboxRequests(be *braceExpr, start, end int64) []*docs.Request { } } -// buildTOCRequest creates requests to insert a Table of Contents. -// Google Docs API does NOT support InsertTableOfContents via batchUpdate. -// This is a documented API limitation — TOC must be inserted manually via the UI. -// -// TODO: When Google adds InsertTableOfContentsRequest to the API, implement it here. -// For now, this function returns nil and the limitation is documented. -func buildTOCRequest(be *braceExpr, _ int64) []*docs.Request { //nolint:unparam // placeholder for future API support - if be == nil || !be.HasTOC { - return nil - } - - // Google Docs API limitation: No InsertTableOfContentsRequest exists. - // The API can read TOC elements but cannot create them programmatically. - // Return nil and document as unsupported. - return nil -} - -// buildCommentRequest creates requests to add a comment/annotation to text. -// Google Docs batchUpdate API does NOT support creating comments. -// Comments must be created via the Drive Comments API (drive.comments.create). -// -// TODO: Implement via Drive API separately if needed. -// For now, this function returns nil and the limitation is documented. -func buildCommentRequest(be *braceExpr, _, _ int64) []*docs.Request { //nolint:unparam // placeholder for future API support - if be == nil || be.Comment == "" { - return nil - } - - // Google Docs API limitation: Comments are not supported in batchUpdate. - // The Drive API (v3) supports comments via drive.comments.create, - // but that requires a separate API call outside of batchUpdate. - // Return nil and document as unsupported in this flow. - return nil -} - // buildBookmarkRequest creates a NamedRange (bookmark) at the matched text. // Bookmarks can be linked via {u=#name} syntax. func buildBookmarkRequest(be *braceExpr, start, end int64) []*docs.Request { diff --git a/internal/cmd/docs_sed_brace_structural_test.go b/internal/cmd/docs_sed_brace_structural_test.go index 4f6c3975f..967b80256 100644 --- a/internal/cmd/docs_sed_brace_structural_test.go +++ b/internal/cmd/docs_sed_brace_structural_test.go @@ -85,15 +85,13 @@ func TestBuildCheckboxRequests_NilExpr(t *testing.T) { func TestBuildTOCRequest_Limitation(t *testing.T) { // TOC is not supported via API — should return nil be := &braceExpr{HasTOC: true, TOC: 3, Indent: indentNotSet} - reqs := buildTOCRequest(be, 10) - assert.Nil(t, reqs, "TOC should return nil due to API limitation") + assertNoStructuralRequests(t, be) } func TestBuildCommentRequest_Limitation(t *testing.T) { // Comments are not supported via batchUpdate — should return nil be := &braceExpr{Comment: "needs review", Indent: indentNotSet} - reqs := buildCommentRequest(be, 1, 10) - assert.Nil(t, reqs, "Comment should return nil due to API limitation") + assertNoStructuralRequests(t, be) } func TestBuildBookmarkRequest(t *testing.T) { @@ -512,3 +510,12 @@ func TestBuildStructuralRequests(t *testing.T) { assert.Nil(t, chipReqs) }) } + +func assertNoStructuralRequests(t *testing.T, be *braceExpr) { + t.Helper() + columns, bullets, anchors, chips := buildStructuralRequests(be, 1, 10, 1, 10) + assert.Nil(t, columns) + assert.Nil(t, bullets) + assert.Nil(t, anchors) + assert.Nil(t, chips) +} diff --git a/internal/cmd/docs_sed_coverage_test.go b/internal/cmd/docs_sed_coverage_test.go index 16fb67f34..005fa5af5 100644 --- a/internal/cmd/docs_sed_coverage_test.go +++ b/internal/cmd/docs_sed_coverage_test.go @@ -549,15 +549,15 @@ func TestLiteralReplacement(t *testing.T) { // --- buildTOCRequest / buildCommentRequest --- func TestBuildTOCRequest(t *testing.T) { - assert.Nil(t, buildTOCRequest(nil, 0)) - assert.Nil(t, buildTOCRequest(&braceExpr{HasTOC: false, Indent: indentNotSet}, 0)) - assert.Nil(t, buildTOCRequest(&braceExpr{HasTOC: true, Indent: indentNotSet}, 0)) // API limitation + assertNoStructuralRequests(t, nil) + assertNoStructuralRequests(t, &braceExpr{HasTOC: false, Indent: indentNotSet}) + assertNoStructuralRequests(t, &braceExpr{HasTOC: true, Indent: indentNotSet}) // API limitation } func TestBuildCommentRequest(t *testing.T) { - assert.Nil(t, buildCommentRequest(nil, 0, 0)) - assert.Nil(t, buildCommentRequest(&braceExpr{Comment: "", Indent: indentNotSet}, 0, 10)) - assert.Nil(t, buildCommentRequest(&braceExpr{Comment: "test", Indent: indentNotSet}, 0, 10)) // API limitation + assertNoStructuralRequests(t, nil) + assertNoStructuralRequests(t, &braceExpr{Comment: "", Indent: indentNotSet}) + assertNoStructuralRequests(t, &braceExpr{Comment: "test", Indent: indentNotSet}) // API limitation } // --- parseFullExpr extended coverage for brace formatting paths --- diff --git a/internal/cmd/docs_update.go b/internal/cmd/docs_update.go new file mode 100644 index 000000000..c171465c7 --- /dev/null +++ b/internal/cmd/docs_update.go @@ -0,0 +1,274 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/alecthomas/kong" + "google.golang.org/api/docs/v1" + + "github.com/openclaw/gogcli/internal/docsedit" + "github.com/openclaw/gogcli/internal/docsmarkdown" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +type DocsUpdateCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Text string `name:"text" help:"Text to insert"` + File string `name:"file" help:"Text file path ('-' for stdin)"` + Index int64 `name:"index" help:"Insert index (default: end of document)"` + ReplaceRange string `name:"replace-range" help:"Replace UTF-16 Docs API range START:END instead of inserting"` + At string `name:"at" help:"Anchor by literal text and replace that matched range"` + Occurrence *int `name:"occurrence" help:"Use the Nth --at match (1-based; required when --at is ambiguous)"` + MatchCase bool `name:"match-case" help:"Use case-sensitive --at matching"` + Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting"` + Pageless bool `name:"pageless" help:"Set document to pageless mode"` + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` + Segment string `name:"segment" help:"Target an exact header, footer, or footnote segment ID"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` + Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` +} + +func (c *DocsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + id := strings.TrimSpace(c.DocID) + if id == "" { + return usage("empty docId") + } + + text, provided, err := resolveTextInput(ctx, c.Text, c.File, kctx) + if err != nil { + return err + } + if !provided { + return usage("required: --text or --file") + } + if text == "" { + return usage("empty text") + } + placement, err := docsedit.PlanUpdatePlacement(docsedit.UpdatePlacementOptions{ + Index: c.Index, + IndexProvided: flagProvided(kctx, "index"), + AllowZero: strings.TrimSpace(c.Segment) != "", + ReplaceRange: c.ReplaceRange, + Anchor: docsedit.AnchorOptions{ + Text: c.At, + Provided: flagProvided(kctx, "at"), + Occurrence: c.Occurrence, + MatchCase: c.MatchCase, + }, + }) + if err != nil { + return usage(err.Error()) + } + at := placement.Anchor.Text + replacing := placement.Kind == docsedit.PlacementRange + replaceRange := placement.Range + replaceStart, replaceEnd := replaceRange.Start, replaceRange.End + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + c.Tab = tab + if strings.TrimSpace(c.Segment) != "" && c.Markdown { + return usage("--segment supports plain-text updates only; markdown can contain structures that are invalid in segments") + } + if c.Batch != "" && (c.Markdown || c.Pageless) { + return usage("--batch supports plain text updates without --pageless") + } + if dryRunErr := dryRunExit(ctx, flags, "docs.update", c.dryRunPayload(id, len(text), replacing, replaceStart, replaceEnd, at)); dryRunErr != nil { + return dryRunErr + } + if batchErr := validateDocsBatchTarget(ctx, flags, c.Batch, id); batchErr != nil { + return batchErr + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, id) + if err != nil { + return err + } + + resolvedPlacement, err := resolveDocsPlacementTarget(ctx, svc, id, c.Tab, c.Segment, placement) + if err != nil { + return err + } + insertIndex := resolvedPlacement.Index + c.Tab = resolvedPlacement.TabID + c.Segment = resolvedPlacement.SegmentID + if resolvedPlacement.Range != nil { + replaceStart = resolvedPlacement.Range.Start + replaceEnd = resolvedPlacement.Range.End + replacing = true + } + + requestCount := 0 + written := len(text) + var resp *docs.BatchUpdateDocumentResponse + + if c.Markdown { + var inserted int + markdown := prepareMarkdown(text) + explicitHeadingAnchors := docsmarkdown.ExplicitHeadingAnchors(markdown.cleaned) + if replacing { + loadedDoc := resolvedPlacement.Document + if loadedDoc == nil { + loaded, loadErr := loadDocsTargetDocument(ctx, svc, id, c.Tab) + if loadErr != nil { + return loadErr + } + c.Tab = loaded.tabID + loadedDoc = loaded.full + } + replacedRequests, replacedText, replaceErr := replacePreparedDocsMarkdownRange( + ctx, + svc, + loadedDoc, + replaceStart, + replaceEnd, + markdown, + c.Tab, + ) + if replaceErr != nil { + err = replaceErr + } else { + inserted = replacedText + requestCount = replacedRequests + } + } else { + var insertResult docsMarkdownInsertResult + insertResult, err = insertPreparedDocsMarkdownAt(ctx, svc, id, insertIndex, markdown, c.Tab, true) + requestCount = insertResult.RequestCount + inserted = insertResult.Inserted + if err == nil && markdownMayContainHeadingLinks(markdown.cleaned) { + var rewritten int + rewritten, err = rewriteMarkdownHeadingLinksInRange( + ctx, + svc, + id, + c.Tab, + explicitHeadingAnchors, + insertResult.ContentStart, + insertResult.ContentEnd, + ) + requestCount += rewritten + } + } + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) + } + return err + } + written = inserted + resp = &docs.BatchUpdateDocumentResponse{DocumentId: id} + } else { + var targetRange *docsedit.Range + if replacing { + targetRange = &docsedit.Range{Start: replaceStart, End: replaceEnd} + } + reqs := docsedit.BuildUpdateRequests(text, insertIndex, c.Tab, targetRange) + applyDocsRequestTarget(reqs, docsTargetFromPlacement(resolvedPlacement.ResolvedPlacement)) + requestCount = len(reqs) + batchReq := &docs.BatchUpdateDocumentRequest{Requests: reqs} + batchReq.WriteControl = docsRequiredRevisionWriteControl(resolvedPlacement.RequiredRevisionID) + if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, id, "docs.update", batchRevision, reqs, false); queued || queueErr != nil { + return queueErr + } + resp, err = svc.Documents.BatchUpdate(id, batchReq).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) + } + return err + } + } + if c.Pageless { + if err := setDocumentPageless(ctx, svc, id); err != nil { + return fmt.Errorf("set pageless mode: %w", err) + } + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": resp.DocumentId, + "requests": requestCount, + "index": insertIndex, + } + if replacing { + payload["replaced"] = true + payload["replaceRange"] = map[string]int64{"start": replaceStart, "end": replaceEnd} + } + if c.Markdown { + payload["written"] = written + payload["markdown"] = true + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + if c.Segment != "" { + payload["segmentId"] = c.Segment + payload["segmentType"] = resolvedPlacement.SegmentKind + } + if resp.WriteControl != nil { + payload["writeControl"] = resp.WriteControl + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("id\t%s", resp.DocumentId) + u.Out().Linef("requests\t%d", requestCount) + u.Out().Linef("index\t%d", insertIndex) + if replacing { + u.Out().Linef("replaced\ttrue") + u.Out().Linef("range\t%d:%d", replaceStart, replaceEnd) + } + if c.Markdown { + u.Out().Linef("written\t%d", written) + u.Out().Linef("markdown\ttrue") + } + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + if c.Segment != "" { + u.Out().Linef("segmentId\t%s", c.Segment) + u.Out().Linef("segmentType\t%s", resolvedPlacement.SegmentKind) + } + if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { + u.Out().Linef("revision\t%s", resp.WriteControl.RequiredRevisionId) + } + return nil +} + +func (c *DocsUpdateCmd) dryRunPayload(docID string, written int, replacing bool, replaceStart, replaceEnd int64, at string) map[string]any { + var index any = docsAtIndexEnd + switch { + case replacing: + index = replaceStart + case at != "": + index = docsAtIndexAnchorStart + case c.Index > 0: + index = c.Index + } + payload := map[string]any{ + "document_id": docID, + "written": written, + "index": index, + "markdown": c.Markdown, + "pageless": c.Pageless, + "tab": c.Tab, + "segment": c.Segment, + "batch": c.Batch, + } + if replacing { + payload["replaceRange"] = map[string]int64{"start": replaceStart, "end": replaceEnd} + } + addDocsAtAnchorDryRunPayload(payload, docsAtAnchorFlags{At: at, Occurrence: c.Occurrence, MatchCase: c.MatchCase}) + return payload +} diff --git a/internal/cmd/docs_write.go b/internal/cmd/docs_write.go new file mode 100644 index 000000000..6e555109c --- /dev/null +++ b/internal/cmd/docs_write.go @@ -0,0 +1,243 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/alecthomas/kong" + "google.golang.org/api/docs/v1" + + "github.com/openclaw/gogcli/internal/docsedit" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +type DocsWriteCmd struct { + DocID string `arg:"" name:"docId" help:"Doc ID"` + Text string `name:"text" help:"Text to write"` + File string `name:"file" help:"Text file path ('-' for stdin)"` + Replace bool `name:"replace" help:"Replace all content explicitly (required with --markdown unless --append is set)"` + Markdown bool `name:"markdown" help:"Convert markdown to Google Docs formatting (requires --replace or --append)"` + Append bool `name:"append" help:"Append instead of replacing the document body"` + CheckOrphans bool `name:"check-orphans" help:"Block markdown replacement when open comment quotes would disappear"` + Pageless bool `name:"pageless" help:"Set document to pageless mode"` + Layout DocsLayoutFlags `embed:""` + Tab string `name:"tab" help:"Target a specific tab by title or ID (see docs list-tabs)"` + TabID string `name:"tab-id" hidden:"" help:"(deprecated) Use --tab"` + Batch string `name:"batch" help:"Append requests to a persisted Docs batch instead of submitting"` + Format DocsFormatFlags `embed:""` +} + +func (c *DocsWriteCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { + id := strings.TrimSpace(c.DocID) + if id == "" { + return usage("empty docId") + } + + text, err := c.resolveWriteText(ctx, kctx) + if err != nil { + return err + } + if c.Append && c.Replace { + return usage("--append cannot be combined with --replace") + } + if c.CheckOrphans && (!c.Markdown || !c.Replace || c.Append) { + return usage("--check-orphans requires --replace --markdown") + } + + tab, tabErr := resolveTabArg(ctx, c.Tab, c.TabID) + if tabErr != nil { + return tabErr + } + if tab == "" && (flagProvided(kctx, "tab") || flagProvided(kctx, "tab-id")) { + return usage("--tab requires a non-empty tab title or ID") + } + c.Tab = tab + + if err := c.validateDocumentStyle(); err != nil { + return err + } + if c.Batch != "" && (c.Markdown || c.Pageless || c.Layout.any()) { + return usage("--batch supports plain text writes without --pageless or layout flags") + } + + if c.Markdown { + if c.Format.any() { + return usage("formatting flags are only supported for plain-text docs write; use markdown syntax or run docs format after writing") + } + return c.writeMarkdown(ctx, flags, id, text) + } + + return c.writePlainText(ctx, flags, id, text) +} + +func (c *DocsWriteCmd) validateDocumentStyle() error { + if !c.Pageless && !c.Layout.any() { + return nil + } + mode := "" + if c.Pageless { + mode = docsDocumentModePageless + } + _, err := buildUpdateDocumentStyleRequest(docsDocumentStyleOptions{ + Mode: mode, + DocsLayoutFlags: c.Layout, + }) + return err +} + +func (c *DocsWriteCmd) resolveWriteText(ctx context.Context, kctx *kong.Context) (string, error) { + text, provided, err := resolveTextInput(ctx, c.Text, c.File, kctx) + if err != nil { + return "", err + } + if !provided { + return "", usage("required: --text or --file") + } + if text == "" { + return "", usage("empty text") + } + return text, nil +} + +func (c *DocsWriteCmd) writePlainText(ctx context.Context, flags *RootFlags, docID, text string) error { + if c.Append && c.Format.createsBullets() && c.Format.hasParagraphStyle() { + return usage("docs write --append cannot combine bullet creation with paragraph formatting; append first, then use docs format") + } + if c.Format.any() { + if _, err := c.Format.buildRequests(1, 1+utf16Len(text), c.Tab); err != nil { + return err + } + } + + dryRunPayload := map[string]any{ + "document_id": docID, + "written": len(text), + "append": c.Append, + "replace": !c.Append, + "markdown": false, + "pageless": c.Pageless, + "tab": c.Tab, + "batch": c.Batch, + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { + return err + } + if err := validateDocsBatchTarget(ctx, flags, c.Batch, docID); err != nil { + return err + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + batchRevision, err := captureDocsBatchRevision(ctx, svc, c.Batch, docID) + if err != nil { + return err + } + + endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) + if err != nil { + return err + } + c.Tab = tabID + insertIndex := int64(1) + if c.Append { + insertIndex = docsedit.AppendIndex(endIndex) + } + + reqs, err := docsedit.BuildWriteRequests(docsedit.WriteOptions{ + EndIndex: endIndex, + InsertIndex: insertIndex, + Text: text, + TabID: c.Tab, + Append: c.Append, + Format: c.Format.options(), + }) + if err != nil { + return usage(err.Error()) + } + if queued, queueErr := queueDocsBatchRequests(ctx, flags, c.Batch, docID, "docs.write", batchRevision, reqs, !c.Append); queued || queueErr != nil { + return queueErr + } + resp, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{Requests: reqs}).Context(ctx).Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { + return err + } + + return c.writePlainTextResult(ctx, resp, len(reqs), insertIndex) +} + +func (c *DocsWriteCmd) applyDocumentStyle(ctx context.Context, svc *docs.Service, docID string) error { + if !c.Pageless && !c.Layout.any() { + return nil + } + mode := "" + if c.Pageless { + mode = docsDocumentModePageless + } + // Document-style fields are per-tab. Resolve --tab to a concrete tab ID so + // pageless/layout lands on the targeted tab rather than silently hitting the + // default tab. resolveDocsTabID is a no-op when the tab is already a concrete + // ID (as in the plain-text path) and skipped entirely when no tab was given. + tabID := "" + if tab := strings.TrimSpace(c.Tab); tab != "" { + resolved, err := resolveDocsTabID(ctx, svc, docID, tab) + if err != nil { + return fmt.Errorf("resolve tab %q: %w", tab, err) + } + tabID = resolved + } + if err := setDocumentStyle(ctx, svc, docID, docsDocumentStyleOptions{ + Mode: mode, + TabID: tabID, + DocsLayoutFlags: c.Layout, + }); err != nil { + return fmt.Errorf("set document style: %w", err) + } + return nil +} + +func (c *DocsWriteCmd) writePlainTextResult(ctx context.Context, resp *docs.BatchUpdateDocumentResponse, requestCount int, insertIndex int64) error { + u := ui.FromContext(ctx) + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": resp.DocumentId, + "requests": requestCount, + "append": c.Append, + "index": insertIndex, + } + if c.Tab != "" { + payload["tabId"] = c.Tab + } + for k, v := range c.Layout.dryRunPayload() { + payload[k] = v + } + if resp.WriteControl != nil { + payload["writeControl"] = resp.WriteControl + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("id\t%s", resp.DocumentId) + u.Out().Linef("requests\t%d", requestCount) + u.Out().Linef("append\t%t", c.Append) + u.Out().Linef("index\t%d", insertIndex) + if c.Tab != "" { + u.Out().Linef("tabId\t%s", c.Tab) + } + if resp.WriteControl != nil && resp.WriteControl.RequiredRevisionId != "" { + u.Out().Linef("revision\t%s", resp.WriteControl.RequiredRevisionId) + } + return nil +} diff --git a/internal/cmd/docs_write_markdown.go b/internal/cmd/docs_write_markdown.go new file mode 100644 index 000000000..78da2d2a6 --- /dev/null +++ b/internal/cmd/docs_write_markdown.go @@ -0,0 +1,408 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "google.golang.org/api/docs/v1" + "google.golang.org/api/drive/v3" + gapi "google.golang.org/api/googleapi" + + "github.com/openclaw/gogcli/internal/docsedit" + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +func (c *DocsWriteCmd) writeMarkdown(ctx context.Context, flags *RootFlags, docID, content string) error { + markdown := prepareMarkdown(content) + plan, err := docsedit.PlanMarkdownWrite(docsedit.MarkdownWriteOptions{ + Markdown: markdown.cleaned, + ImageCount: len(markdown.images), + Append: c.Append, + Replace: c.Replace, + Tab: c.Tab, + CheckOrphans: c.CheckOrphans, + ApplyDocumentStyle: c.Pageless || c.Layout.any(), + }) + if err != nil { + return usage(err.Error()) + } + + switch plan.Mode { + case docsedit.MarkdownWriteDriveReplace: + return c.replaceMarkdownWithDrive(ctx, flags, docID, markdown, plan) + case docsedit.MarkdownWriteLocalAppend: + return c.appendMarkdown(ctx, flags, docID, markdown, plan) + case docsedit.MarkdownWriteLocalReplace: + return c.replaceMarkdownLocally(ctx, flags, docID, markdown, plan) + default: + return fmt.Errorf("unsupported markdown write mode: %d", plan.Mode) + } +} + +func (c *DocsWriteCmd) replaceMarkdownWithDrive( + ctx context.Context, + flags *RootFlags, + docID string, + markdown preparedMarkdown, + plan docsedit.MarkdownWritePlan, +) error { + u := ui.FromContext(ctx) + dryRunPayload := map[string]any{ + "document_id": docID, + "written": len(markdown.source), + "append": false, + "replace": true, + "markdown": true, + "pageless": c.Pageless, + "images": plan.ImageCount, + "check_orphans": plan.CheckOrphans, + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { + return err + } + + account, driveSvc, err := requireDriveService(ctx, flags) + if err != nil { + return err + } + + var docsSvc *docs.Service + if plan.CheckOrphans { + docsSvc, err = docsService(ctx, account) + if err != nil { + return err + } + orphans, tabID, orphanErr := findDocsWriteMarkdownOrphans( + ctx, + driveSvc, + docsSvc, + docID, + markdown, + plan.Tab, + plan.OrphanScopeWholeDocument, + ) + if orphanErr != nil { + return orphanErr + } + if resultErr := writeDocsWriteOrphanResult(ctx, docID, tabID, orphans); resultErr != nil { + return resultErr + } + } + + updated, err := driveSvc.Files.Update(docID, &drive.File{}). + Media(strings.NewReader(plan.Markdown), gapi.ContentType(mimeTextMarkdown)). + SupportsAllDrives(true). + Fields("id,name,webViewLink"). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("writing markdown to document: %w", err) + } + + if plan.RequiresDocumentsService && docsSvc == nil { + var svcErr error + docsSvc, svcErr = docsService(ctx, account) + if svcErr != nil { + return svcErr + } + } + rewrittenHeadingLinks := 0 + if plan.RewriteHeadingLinks { + count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, docsSvc, docID, plan.Tab, plan.ExplicitHeadingAnchors) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + rewrittenHeadingLinks = count + } + if plan.InsertImages { + if err := insertImagesIntoDocs(ctx, docsSvc, docID, markdown.images, plan.Tab); err != nil { + cleanupDocsImagePlaceholders(ctx, docsSvc, docID, markdown.images, plan.Tab) + return fmt.Errorf("insert images: %w", err) + } + } + if plan.ApplyDocumentStyle { + if err := c.applyDocumentStyle(ctx, docsSvc, docID); err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": updated.Id, + "written": len(markdown.source), + "replaced": true, + "markdown": true, + } + if c.Pageless { + payload["pageless"] = true + } + if rewrittenHeadingLinks > 0 { + payload["headingLinks"] = rewrittenHeadingLinks + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u.Out().Linef("documentId\t%s", updated.Id) + u.Out().Linef("written\t%d", len(markdown.source)) + u.Out().Linef("mode\treplaced (markdown converted)") + if c.Pageless { + u.Out().Linef("pageless\ttrue") + } + if rewrittenHeadingLinks > 0 { + u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) + } + if updated.WebViewLink != "" { + u.Out().Linef("link\t%s", updated.WebViewLink) + } + return nil +} + +func (c *DocsWriteCmd) appendMarkdown( + ctx context.Context, + flags *RootFlags, + docID string, + markdown preparedMarkdown, + plan docsedit.MarkdownWritePlan, +) error { + dryRunPayload := map[string]any{ + "document_id": docID, + "written": len(plan.Markdown), + "append": true, + "replace": false, + "markdown": true, + "pageless": c.Pageless, + "tab": plan.Tab, + "images": plan.ImageCount, + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { + return err + } + + svc, err := requireDocsService(ctx, flags) + if err != nil { + return err + } + + endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) + if err != nil { + return err + } + c.Tab = tabID + insertIndex := docsedit.AppendIndex(endIndex) + insertResult, err := insertPreparedDocsMarkdownAt(ctx, svc, docID, insertIndex, markdown, c.Tab, true) + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + if plan.ApplyDocumentStyle { + if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { + return err + } + } + rewrittenHeadingLinks := 0 + if plan.RewriteHeadingLinks { + count, rewriteErr := rewriteMarkdownHeadingLinksFromIndex( + ctx, + svc, + docID, + c.Tab, + plan.ExplicitHeadingAnchors, + insertResult.ContentStart, + ) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + rewrittenHeadingLinks = count + insertResult.RequestCount += count + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "written": insertResult.Inserted, + "requests": insertResult.RequestCount, + "append": true, + "index": insertIndex, + "markdown": true, + } + if c.Pageless { + payload["pageless"] = true + } + if rewrittenHeadingLinks > 0 { + payload["headingLinks"] = rewrittenHeadingLinks + } + for k, v := range c.Layout.dryRunPayload() { + payload[k] = v + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u := ui.FromContext(ctx) + u.Out().Linef("documentId\t%s", docID) + u.Out().Linef("written\t%d", insertResult.Inserted) + u.Out().Linef("requests\t%d", insertResult.RequestCount) + u.Out().Linef("mode\tappended (markdown converted)") + u.Out().Linef("index\t%d", insertIndex) + if rewrittenHeadingLinks > 0 { + u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) + } + if c.Pageless { + u.Out().Linef("pageless\ttrue") + } + return nil +} + +// replaceMarkdownLocally renders Markdown through Docs batchUpdate after +// clearing the selected body. This preserves tab targeting and table-cell +// line breaks that Drive's whole-document converter cannot represent. +func (c *DocsWriteCmd) replaceMarkdownLocally( + ctx context.Context, + flags *RootFlags, + docID string, + markdown preparedMarkdown, + plan docsedit.MarkdownWritePlan, +) error { + dryRunPayload := map[string]any{ + "document_id": docID, + "written": len(plan.Markdown), + "append": false, + "replace": true, + "markdown": true, + "pageless": c.Pageless, + "tab": plan.Tab, + "images": plan.ImageCount, + "check_orphans": plan.CheckOrphans, + } + for k, v := range c.Layout.dryRunPayload() { + dryRunPayload[k] = v + } + if err := dryRunExit(ctx, flags, "docs.write", dryRunPayload); err != nil { + return err + } + + var svc *docs.Service + var err error + if plan.CheckOrphans { + account, driveSvc, driveErr := requireDriveService(ctx, flags) + if driveErr != nil { + return driveErr + } + svc, err = docsService(ctx, account) + if err != nil { + return err + } + orphans, resolvedTabID, orphanErr := findDocsWriteMarkdownOrphans( + ctx, + driveSvc, + svc, + docID, + markdown, + plan.Tab, + plan.OrphanScopeWholeDocument, + ) + if orphanErr != nil { + return orphanErr + } + if resultErr := writeDocsWriteOrphanResult(ctx, docID, resolvedTabID, orphans); resultErr != nil { + return resultErr + } + c.Tab = resolvedTabID + } else { + svc, err = requireDocsService(ctx, flags) + } + if err != nil { + return err + } + + endIndex, tabID, err := docsTargetEndIndexAndTabID(ctx, svc, docID, c.Tab) + if err != nil { + return err + } + c.Tab = tabID + + // Wipe existing tab body (everything between the implicit start index 1 + // and the last segment endIndex - 1). Skipped when the tab is already + // empty (endIndex <= 2 means a single newline segment). + deleteEnd := endIndex - 1 + if deleteEnd > 1 { + if _, derr := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{ + DeleteContentRange: &docs.DeleteContentRangeRequest{ + Range: &docs.Range{StartIndex: 1, EndIndex: deleteEnd, TabId: tabID}, + }, + }}, + }).Context(ctx).Do(); derr != nil { + if isDocsNotFound(derr) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return fmt.Errorf("clear tab content: %w", derr) + } + } + + insertResult, err := insertPreparedDocsMarkdownAt(ctx, svc, docID, 1, markdown, tabID, true) + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + if plan.ApplyDocumentStyle { + if err := c.applyDocumentStyle(ctx, svc, docID); err != nil { + return err + } + } + rewrittenHeadingLinks := 0 + if plan.RewriteHeadingLinks { + count, rewriteErr := rewriteMarkdownHeadingLinks(ctx, svc, docID, tabID, plan.ExplicitHeadingAnchors) + if rewriteErr != nil { + return fmt.Errorf("rewrite heading links: %w", rewriteErr) + } + rewrittenHeadingLinks = count + insertResult.RequestCount += count + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "documentId": docID, + "written": insertResult.Inserted, + "requests": insertResult.RequestCount, + "replaced": true, + "markdown": true, + "tabId": tabID, + } + if c.Pageless { + payload["pageless"] = true + } + if rewrittenHeadingLinks > 0 { + payload["headingLinks"] = rewrittenHeadingLinks + } + for k, v := range c.Layout.dryRunPayload() { + payload[k] = v + } + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload) + } + + u := ui.FromContext(ctx) + u.Out().Linef("documentId\t%s", docID) + u.Out().Linef("written\t%d", insertResult.Inserted) + u.Out().Linef("requests\t%d", insertResult.RequestCount) + u.Out().Linef("mode\treplaced tab (markdown converted)") + u.Out().Linef("tabId\t%s", tabID) + if rewrittenHeadingLinks > 0 { + u.Out().Linef("headingLinks\t%d", rewrittenHeadingLinks) + } + if c.Pageless { + u.Out().Linef("pageless\ttrue") + } + return nil +} diff --git a/internal/cmd/drive.go b/internal/cmd/drive.go index 734c9a41e..d13585a12 100644 --- a/internal/cmd/drive.go +++ b/internal/cmd/drive.go @@ -93,6 +93,8 @@ type DriveLsCmd struct { All bool `name:"all" aliases:"global" help:"List all accessible files (mutually exclusive with --parent)"` AllDrives bool `name:"all-drives" help:"Include shared drives (default: true; use --no-all-drives for My Drive only)" default:"true" negatable:"_"` Fields string `name:"fields" help:"Drive API field mask (overrides the default set; e.g. 'files(id,name,thumbnailLink),nextPageToken')"` + Sort string `name:"sort" default:"modifiedTime" enum:"createdTime,folder,modifiedByMeTime,modifiedTime,name,name_natural,quotaBytesUsed,recency,sharedWithMeTime,starred,viewedByMeTime" help:"Drive API sort key"` + Order string `name:"order" default:"desc" enum:"asc,desc" help:"Sort direction"` } type DriveSearchCmd struct { diff --git a/internal/cmd/drive_listing.go b/internal/cmd/drive_listing.go index 035d64941..f498d9f22 100644 --- a/internal/cmd/drive_listing.go +++ b/internal/cmd/drive_listing.go @@ -19,6 +19,7 @@ type driveFileListOptions struct { allDrives bool driveID string fields string // optional field mask override + orderBy string } func (c *DriveLsCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -43,6 +44,10 @@ func (c *DriveLsCmd) Run(ctx context.Context, flags *RootFlags) error { if c.All { query = buildDriveAllListQuery(c.Query) } + orderBy := c.Sort + if c.Order == "desc" { + orderBy += " desc" + } resp, err := listDriveFiles(ctx, svc, driveFileListOptions{ query: query, @@ -50,6 +55,7 @@ func (c *DriveLsCmd) Run(ctx context.Context, flags *RootFlags) error { page: c.Page, allDrives: c.AllDrives, fields: c.Fields, + orderBy: orderBy, }) if err != nil { return err @@ -101,11 +107,15 @@ func (c *DriveSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } func listDriveFiles(ctx context.Context, svc *drive.Service, opts driveFileListOptions) (*drive.FileList, error) { + orderBy := strings.TrimSpace(opts.orderBy) + if orderBy == "" { + orderBy = "modifiedTime desc" + } call := svc.Files.List(). Q(opts.query). PageSize(opts.max). PageToken(opts.page). - OrderBy("modifiedTime desc") + OrderBy(orderBy) call = driveFilesListCallWithDriveSupport(call, opts.allDrives, opts.driveID) mask := driveFileListFields if strings.TrimSpace(opts.fields) != "" { diff --git a/internal/cmd/drive_ls_cmd_test.go b/internal/cmd/drive_ls_cmd_test.go index 58e17d60b..f416bf41d 100644 --- a/internal/cmd/drive_ls_cmd_test.go +++ b/internal/cmd/drive_ls_cmd_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "slices" "strings" "testing" @@ -20,6 +21,9 @@ func TestDriveLsCmd_TextAndJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && (r.URL.Path == "/drive/v3/files" || r.URL.Path == "/files"): + if got := r.URL.Query().Get("orderBy"); got != "modifiedTime desc" { + t.Errorf("default orderBy = %q", got) + } if errMsg := driveAllDrivesQueryError(r, true); errMsg != "" { http.Error(w, errMsg, http.StatusBadRequest) return @@ -186,3 +190,46 @@ func TestDriveLsCmd_NoAllDrives(t *testing.T) { t.Fatalf("execute: %v", execErr) } } + +func TestDriveLsCmd_Sort(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"--sort", "modifiedByMeTime"}, "modifiedByMeTime desc"}, + {[]string{"--sort", "name_natural", "--order", "asc"}, "name_natural"}, + {[]string{"--order", "asc"}, "modifiedTime"}, + } { + t.Run(tc.want, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("orderBy"); got != tc.want { + t.Errorf("orderBy = %q, want %q", got, tc.want) + } + if got := r.URL.Query().Get("pageToken"); got != "next-page" { + t.Errorf("pageToken = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"files":[]}`) + })) + defer srv.Close() + svc, err := drive.NewService(context.Background(), option.WithoutAuthentication(), option.WithHTTPClient(srv.Client()), option.WithEndpoint(srv.URL+"/")) + if err != nil { + t.Fatal(err) + } + ctx := withDriveTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc) + args := slices.Clone(tc.args) + args = append(args, "--page", "next-page") + if err := runKong(t, &DriveLsCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestDriveLsCmd_InvalidSort(t *testing.T) { + for _, args := range [][]string{{"--sort", "unknown"}, {"--order", "sideways"}} { + if err := runKong(t, &DriveLsCmd{}, args, context.Background(), &RootFlags{}); err == nil { + t.Fatalf("expected invalid sort options to fail: %v", args) + } + } +} diff --git a/internal/cmd/gmail_watch_state.go b/internal/cmd/gmail_watch_state.go index b01355a14..ab01d5a0f 100644 --- a/internal/cmd/gmail_watch_state.go +++ b/internal/cmd/gmail_watch_state.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strings" - "unicode" "github.com/openclaw/gogcli/internal/config" "github.com/openclaw/gogcli/internal/gmailwatch" @@ -62,10 +61,6 @@ func sanitizeAccountForPath(account string) string { builder.WriteRune(char) case char >= '0' && char <= '9': builder.WriteRune(char) - case char == '.' || char == '-' || char == '_' || char == '@': - builder.WriteRune('_') - case char > unicode.MaxASCII: - builder.WriteRune('_') default: builder.WriteRune('_') } diff --git a/internal/cmd/searchconsole.go b/internal/cmd/searchconsole.go index 2668761ef..8f30df8bc 100644 --- a/internal/cmd/searchconsole.go +++ b/internal/cmd/searchconsole.go @@ -11,6 +11,7 @@ import ( gapi "google.golang.org/api/googleapi" searchconsoleapi "google.golang.org/api/searchconsole/v1" + "github.com/openclaw/gogcli/internal/errfmt" "github.com/openclaw/gogcli/internal/outfmt" "github.com/openclaw/gogcli/internal/ui" ) @@ -20,6 +21,7 @@ type SearchConsoleCmd struct { SearchAnalytics SearchConsoleSearchAnalyticsCmd `cmd:"" name:"searchanalytics" aliases:"analytics" help:"Search Analytics queries"` Query SearchConsoleQueryCmd `cmd:"" name:"query" aliases:"report" help:"Run a Search Analytics query"` Sitemaps SearchConsoleSitemapsCmd `cmd:"" name:"sitemaps" help:"List/get/submit/delete sitemaps"` + Inspect SearchConsoleInspectCmd `cmd:"" help:"Inspect URL index status (URL Inspection API)"` } type SearchConsoleSitesCmd struct { @@ -500,9 +502,9 @@ func wrapSearchConsoleError(err error) error { message := strings.ToLower(apiErr.Message) switch { case strings.Contains(message, "accessnotconfigured"), strings.Contains(message, "api has not been used"): - return fmt.Errorf("search console API is not enabled for this OAuth project. Enable it at https://console.cloud.google.com/apis/library/searchconsole.googleapis.com") + return errfmt.NewUserFacingError("search console API is not enabled for this OAuth project. Enable it at https://console.cloud.google.com/apis/library/searchconsole.googleapis.com", err) case strings.Contains(message, "insufficientpermissions"), strings.Contains(message, "insufficient permission"): - return fmt.Errorf("insufficient permissions for Search Console API. Re-authorize with: gog auth add <email> --services searchconsole") + return errfmt.NewUserFacingError("insufficient permissions for Search Console API. Re-authorize with: gog auth add <email> --services searchconsole", err) default: return err } diff --git a/internal/cmd/searchconsole_inspect.go b/internal/cmd/searchconsole_inspect.go new file mode 100644 index 000000000..55259e4d6 --- /dev/null +++ b/internal/cmd/searchconsole_inspect.go @@ -0,0 +1,91 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + searchconsoleapi "google.golang.org/api/searchconsole/v1" + + "github.com/openclaw/gogcli/internal/outfmt" +) + +type SearchConsoleInspectCmd struct { + SiteURL string `arg:"" name:"siteUrl" help:"Search Console property URL (e.g. https://example.com/ or sc-domain:example.com)"` + InspectionURL string `arg:"" name:"url" help:"URL to inspect; must live under the property"` + + Language string `name:"language" help:"BCP-47 language for issue messages (e.g. zh-TW)" default:"en-US"` +} + +func (c *SearchConsoleInspectCmd) Run(ctx context.Context, flags *RootFlags) error { + siteURL := strings.TrimSpace(c.SiteURL) + if siteURL == "" { + return usage("empty siteUrl") + } + inspectionURL := strings.TrimSpace(c.InspectionURL) + if inspectionURL == "" { + return usage("empty url") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := searchConsoleService(ctx, account) + if err != nil { + return err + } + + req := &searchconsoleapi.InspectUrlIndexRequest{ + SiteUrl: siteURL, + InspectionUrl: inspectionURL, + LanguageCode: strings.TrimSpace(c.Language), + } + resp, err := svc.UrlInspection.Index.Inspect(req).Context(ctx).Do() + if err != nil { + return wrapSearchConsoleError(err) + } + + res := resp.InspectionResult + if res == nil { + return fmt.Errorf("empty inspection result") + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "site_url": siteURL, + "inspection_url": inspectionURL, + "inspectionResult": res, + }) + } + + idx := res.IndexStatusResult + if idx == nil { + idx = &searchconsoleapi.IndexStatusInspectionResult{} + } + canonical := sanitizeTab(idx.GoogleCanonical) + if idx.UserCanonical != "" && idx.UserCanonical != idx.GoogleCanonical { + canonical = fmt.Sprintf("%s (user: %s)", canonical, sanitizeTab(idx.UserCanonical)) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "FIELD\tVALUE") + fmt.Fprintf(w, "url\t%s\n", sanitizeTab(inspectionURL)) + fmt.Fprintf(w, "verdict\t%s\n", sanitizeTab(idx.Verdict)) + fmt.Fprintf(w, "coverage_state\t%s\n", sanitizeTab(idx.CoverageState)) + fmt.Fprintf(w, "indexing_state\t%s\n", sanitizeTab(idx.IndexingState)) + fmt.Fprintf(w, "page_fetch_state\t%s\n", sanitizeTab(idx.PageFetchState)) + fmt.Fprintf(w, "robots_txt_state\t%s\n", sanitizeTab(idx.RobotsTxtState)) + fmt.Fprintf(w, "crawled_as\t%s\n", sanitizeTab(idx.CrawledAs)) + fmt.Fprintf(w, "last_crawl_time\t%s\n", sanitizeTab(idx.LastCrawlTime)) + fmt.Fprintf(w, "canonical\t%s\n", canonical) + if n := len(idx.Sitemap); n > 0 { + fmt.Fprintf(w, "sitemap\t%s\n", sanitizeTab(strings.Join(idx.Sitemap, ", "))) + } + if n := len(idx.ReferringUrls); n > 0 { + fmt.Fprintf(w, "referring_urls\t%d\n", n) + } + return nil +} diff --git a/internal/cmd/searchconsole_more_test.go b/internal/cmd/searchconsole_more_test.go index 8ea41568d..8e9e8a76a 100644 --- a/internal/cmd/searchconsole_more_test.go +++ b/internal/cmd/searchconsole_more_test.go @@ -1,13 +1,16 @@ package cmd import ( + "context" "encoding/json" "errors" + "fmt" "net/http" "strings" "testing" gapi "google.golang.org/api/googleapi" + searchconsoleapi "google.golang.org/api/searchconsole/v1" "github.com/openclaw/gogcli/internal/app" ) @@ -288,3 +291,160 @@ func TestExecute_SearchConsoleSitemaps_InvalidFeedPathIsUsageBeforeDryRun(t *tes }) } } + +func TestExecute_SearchConsoleInspect_JSON(t *testing.T) { + svc := newSearchConsoleTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/urlInspection/index:inspect")) { + http.NotFound(w, r) + return + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req["siteUrl"] != "sc-domain:example.com" || req["inspectionUrl"] != "https://example.com/docs" || req["languageCode"] != "en-US" { + t.Fatalf("unexpected request payload: %#v", req) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inspectionResult": map[string]any{ + "inspectionResultLink": "https://search.google.com/search-console/inspect?resource_id=sc-domain:example.com", + "indexStatusResult": map[string]any{ + "verdict": "NEUTRAL", + "coverageState": "Discovered - currently not indexed", + "indexingState": "INDEXING_ALLOWED", + "pageFetchState": "SUCCESSFUL", + "robotsTxtState": "ALLOWED", + "lastCrawlTime": "2026-08-01T00:00:00Z", + }, + }, + }) + })) + result := executeWithSearchConsoleTestService(t, []string{ + "--json", + "--account", "a@b.com", + "searchconsole", "inspect", "sc-domain:example.com", "https://example.com/docs", + }, svc) + if result.err != nil { + t.Fatalf("Execute: %v", result.err) + } + + var parsed struct { + SiteURL string `json:"site_url"` + URL string `json:"inspection_url"` + Result struct { + InspectionResultLink string `json:"inspectionResultLink"` + IndexStatusResult struct { + Verdict string `json:"verdict"` + CoverageState string `json:"coverageState"` + } `json:"indexStatusResult"` + } `json:"inspectionResult"` + } + if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.SiteURL != "sc-domain:example.com" || parsed.URL != "https://example.com/docs" { + t.Fatalf("unexpected payload: %#v", parsed) + } + if parsed.Result.IndexStatusResult.Verdict != "NEUTRAL" || parsed.Result.IndexStatusResult.CoverageState != "Discovered - currently not indexed" { + t.Fatalf("unexpected index status: %#v", parsed.Result.IndexStatusResult) + } +} + +func TestExecute_SearchConsoleInspect_Text(t *testing.T) { + svc := newSearchConsoleTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/urlInspection/index:inspect")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "inspectionResult": map[string]any{ + "indexStatusResult": map[string]any{ + "verdict": "PASS", + "coverageState": "Submitted and indexed", + }, + }, + }) + })) + result := executeWithSearchConsoleTestService(t, []string{ + "--account", "a@b.com", + "searchconsole", "inspect", "sc-domain:example.com", "https://example.com/docs", + }, svc) + if result.err != nil { + t.Fatalf("Execute: %v", result.err) + } + if !strings.Contains(result.stdout, "verdict") || !strings.Contains(result.stdout, "PASS") || !strings.Contains(result.stdout, "coverage_state") || !strings.Contains(result.stdout, "Submitted and indexed") { + t.Fatalf("unexpected out=%q", result.stdout) + } +} + +func TestExecute_SearchConsoleInspect_EmptyArgsAreUsageBeforeServiceCall(t *testing.T) { + testCases := [][]string{ + {"--account", "a@b.com", "searchconsole", "inspect", " ", "https://example.com/docs"}, + {"--account", "a@b.com", "searchconsole", "inspect", "sc-domain:example.com", " "}, + } + for _, args := range testCases { + t.Run(strings.Join(args[3:], "_"), func(t *testing.T) { + result := executeWithSearchConsoleTestServiceFactory( + t, + args, + unexpectedSearchConsoleTestService(t, "expected validation to fail before creating search console service"), + ) + var exitErr *ExitError + if !errors.As(result.err, &exitErr) || exitErr.Code != 2 { + t.Fatalf("unexpected err: %v", result.err) + } + }) + } +} + +func TestExecute_SearchConsoleInspect_ServiceError(t *testing.T) { + result := executeWithSearchConsoleTestServiceFactory( + t, + []string{"--account", "a@b.com", "searchconsole", "inspect", "sc-domain:example.com", "https://example.com/docs"}, + func(context.Context, string) (*searchconsoleapi.Service, error) { + return nil, errors.New("search console service down") + }, + ) + if result.err == nil || !strings.Contains(result.err.Error(), "search console service down") { + t.Fatalf("unexpected err: %v", result.err) + } +} + +func TestExecute_SearchConsoleInspect_EmptyResultIsError(t *testing.T) { + for _, jsonOutput := range []bool{false, true} { + t.Run(fmt.Sprint(jsonOutput), func(t *testing.T) { + svc := newSearchConsoleTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + args := []string{"--account", "a@b.com", "searchconsole", "inspect", "sc-domain:example.com", "https://example.com/docs"} + if jsonOutput { + args = append([]string{"--json"}, args...) + } + result := executeWithSearchConsoleTestService(t, args, svc) + if result.err == nil || !strings.Contains(result.err.Error(), "empty inspection result") { + t.Fatalf("expected missing result error, got %v", result.err) + } + if result.stdout != "" { + t.Fatalf("unexpected successful output: %s", result.stdout) + } + }) + } +} + +func TestWrapSearchConsoleErrorPreservesPermissionExitCode(t *testing.T) { + for _, message := range []string{"accessNotConfigured: API has not been used", "insufficientPermissions"} { + t.Run(message, func(t *testing.T) { + cause := &gapi.Error{Code: http.StatusForbidden, Message: message} + err := wrapSearchConsoleError(cause) + if !errors.Is(err, cause) { + t.Errorf("provider error lost: %v", err) + } + if code := ExitCode(stableExitCode(err)); code != exitCodePermissionDenied { + t.Fatalf("exit code = %d, want %d", code, exitCodePermissionDenied) + } + }) + } +} diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index e96af6905..5e9de620d 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -65,6 +65,7 @@ type SheetsCmd struct { Export SheetsExportCmd `cmd:"" name:"export" aliases:"download,dl" help:"Export a Google Sheet (pdf|xlsx|csv) via Drive"` Chart SheetsChartCmd `cmd:"" name:"chart" aliases:"charts" help:"Manage spreadsheet charts"` AddTab SheetsAddTabCmd `cmd:"" name:"add-tab" aliases:"add-sheet" help:"Add a new tab/sheet to a spreadsheet"` + DuplicateTab SheetsDuplicateTabCmd `cmd:"" name:"duplicate-tab" help:"Duplicate a tab within a spreadsheet"` RenameTab SheetsRenameTabCmd `cmd:"" name:"rename-tab" aliases:"rename-sheet" help:"Rename a tab/sheet in a spreadsheet"` DeleteTab SheetsDeleteTabCmd `cmd:"" name:"delete-tab" aliases:"delete-sheet" help:"Delete a tab/sheet from a spreadsheet (use --force to skip confirmation)"` ReorderTab SheetsReorderTabCmd `cmd:"" name:"reorder-tab" aliases:"move-tab,reorder-sheet,move-sheet" help:"Move a tab/sheet to a specific 0-based position in the spreadsheet"` @@ -687,6 +688,7 @@ func (c *SheetsClearCmd) Run(ctx context.Context, flags *RootFlags) error { // Go type: https://pkg.go.dev/google.golang.org/api/sheets/v4#Spreadsheet type SheetsRawCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Sheet string `name:"sheet" help:"Return only this sheet (exact tab title); spreadsheet-level metadata remains included"` IncludeGridData bool `name:"include-grid-data" help:"Include cell-level grid data in the response (off by default; payloads can be large and may contain secrets in formulas)"` Pretty bool `name:"pretty" help:"Pretty-print JSON (default: compact single-line)"` } @@ -704,6 +706,10 @@ func (c *SheetsRawCmd) Run(ctx context.Context, flags *RootFlags) error { } call := svc.Spreadsheets.Get(spreadsheetID).Context(ctx) + if c.Sheet != "" { + // Always quote the title so A1-like names cannot resolve to cell ranges. + call = call.Ranges("'" + strings.ReplaceAll(c.Sheet, "'", "''") + "'") + } if c.IncludeGridData { call = call.IncludeGridData(true) u.Err().Println("warning: --include-grid-data may expose cell-level formulas that contain API keys or hardcoded secrets") diff --git a/internal/cmd/sheets_duplicate_tab.go b/internal/cmd/sheets_duplicate_tab.go new file mode 100644 index 000000000..0a3991a51 --- /dev/null +++ b/internal/cmd/sheets_duplicate_tab.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "context" + "errors" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/openclaw/gogcli/internal/outfmt" + "github.com/openclaw/gogcli/internal/ui" +) + +type SheetsDuplicateTabCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + SourceTab string `arg:"" name:"sourceTab" help:"Source tab title or numeric sheet ID"` + NewName string `arg:"" name:"newName" help:"Name of the duplicated tab"` + Index *int64 `name:"index" help:"Zero-based insertion index (default: directly after the source tab)"` +} + +func (c *SheetsDuplicateTabCmd) Run(ctx context.Context, flags *RootFlags) error { + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + sourceTab := strings.TrimSpace(c.SourceTab) + newName := strings.TrimSpace(c.NewName) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if sourceTab == "" || newName == "" { + return usage("sourceTab and newName must not be empty") + } + if c.Index != nil && *c.Index < 0 { + return usage("--index must be >= 0") + } + if err := dryRunExit(ctx, flags, "sheets.duplicate-tab", map[string]any{ + "spreadsheet_id": spreadsheetID, + "source_tab": sourceTab, + "new_name": newName, + "index": c.Index, + }); err != nil { + return err + } + _, svc, err := requireSheetsService(ctx, flags) + if err != nil { + return err + } + target, err := resolveSheetTab(ctx, svc, spreadsheetID, sourceTab) + if err != nil { + return err + } + index := target.Index + 1 + if c.Index != nil { + index = *c.Index + } + if index > int64(target.Count) { + return usagef("--index must be between 0 and %d", target.Count) + } + request := &sheets.DuplicateSheetRequest{ + SourceSheetId: target.ID, + NewSheetName: newName, + InsertSheetIndex: index, + ForceSendFields: []string{"SourceSheetId", "InsertSheetIndex"}, + } + resp, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{DuplicateSheet: request}}, + }).Context(ctx).Do() + if err != nil { + return err + } + if resp == nil || len(resp.Replies) != 1 || resp.Replies[0] == nil || resp.Replies[0].DuplicateSheet == nil || resp.Replies[0].DuplicateSheet.Properties == nil { + return errors.New("duplicate-tab: Google returned no new tab properties; inspect the spreadsheet before retrying") + } + props := resp.Replies[0].DuplicateSheet.Properties + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ + "spreadsheetId": spreadsheetID, + "sourceSheetId": target.ID, + "sheetId": props.SheetId, + "title": props.Title, + "index": props.Index, + }) + } + ui.FromContext(ctx).Out().Linef("Duplicated tab %q as %q (sheetId %d, index %d)", target.Title, props.Title, props.SheetId, props.Index) + return nil +} diff --git a/internal/cmd/sheets_duplicate_tab_test.go b/internal/cmd/sheets_duplicate_tab_test.go new file mode 100644 index 000000000..9e980708b --- /dev/null +++ b/internal/cmd/sheets_duplicate_tab_test.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/openclaw/gogcli/internal/outfmt" +) + +func TestSheetsDuplicateTab(t *testing.T) { + for _, tc := range []struct { + name string + args []string + index float64 + }{ + {"default", nil, 1}, + {"first", []string{"--index", "0"}, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + _, _ = io.WriteString(w, `{"sheets":[{"properties":{"sheetId":0,"title":"Data","index":0}}]}`) + return + } + var body struct { + Requests []struct { + DuplicateSheet map[string]any `json:"duplicateSheet"` + } `json:"requests"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + return + } + if len(body.Requests) != 1 { + t.Errorf("requests = %v", body.Requests) + return + } + got := body.Requests[0].DuplicateSheet + if v, ok := got["sourceSheetId"]; !ok || v != float64(0) { + t.Errorf("sourceSheetId missing or wrong: %v", got) + } + if v, ok := got["insertSheetIndex"]; !ok || v != tc.index { + t.Errorf("insertSheetIndex missing or wrong: %v", got) + } + if got["newSheetName"] != "Backup" { + t.Errorf("name = %v", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{"replies": []any{map[string]any{"duplicateSheet": map[string]any{"properties": map[string]any{"sheetId": 7, "title": "Backup", "index": tc.index}}}}}) + })) + defer srv.Close() + var output bytes.Buffer + ctx := withSheetsTestService(newCmdRuntimeOutputContext(t, &output, io.Discard), newSheetsServiceFromServer(t, srv)) + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + args := append([]string{"s1", "Data", "Backup"}, tc.args...) + if err := runKong(t, &SheetsDuplicateTabCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["sheetId"] != float64(7) || result["title"] != "Backup" || result["index"] != tc.index { + t.Fatalf("unexpected result: %v", result) + } + }) + } +} + +func TestSheetsDuplicateTab_RejectsBeforeMutation(t *testing.T) { + for _, args := range [][]string{{"s1", "missing", "Backup"}, {"s1", "Data", "Backup", "--index=2"}} { + capture := &sheetsBatchUpdateCapture{} + svc := newSheetsBatchUpdateTestService(t, map[string]any{"sheets": []any{map[string]any{"properties": map[string]any{"sheetId": 0, "title": "Data"}}}}, capture) + ctx := withSheetsTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc) + if err := runKong(t, &SheetsDuplicateTabCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err == nil || capture.Body != nil { + t.Fatalf("expected rejection before mutation, err=%v body=%v", err, capture.Body) + } + } +} + +func TestSheetsDuplicateTab_DryRunWithoutAuth(t *testing.T) { + ctx := newCmdRuntimeOutputContext(t, io.Discard, io.Discard) + err := runKong(t, &SheetsDuplicateTabCmd{}, []string{"s1", "Data", "Backup"}, ctx, &RootFlags{DryRun: true, NoInput: true}) + if err != nil && ExitCode(err) != 0 { + t.Fatal(err) + } +} + +func TestSheetsDuplicateTab_MissingReply(t *testing.T) { + capture := &sheetsBatchUpdateCapture{} + svc := newSheetsBatchUpdateTestService(t, map[string]any{"sheets": []any{map[string]any{"properties": map[string]any{"sheetId": 0, "title": "Data"}}}}, capture) + ctx := withSheetsTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc) + err := runKong(t, &SheetsDuplicateTabCmd{}, []string{"s1", "Data", "Backup"}, ctx, &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "inspect the spreadsheet before retrying") { + t.Fatalf("expected incomplete-response guidance, got %v", err) + } +} + +func TestSheetsDuplicateTab_Validation(t *testing.T) { + for _, args := range [][]string{{"", "Data", "Backup"}, {"s1", "", "Backup"}, {"s1", "Data", ""}, {"s1", "Data", "Backup", "--index=-1"}} { + if err := runKong(t, &SheetsDuplicateTabCmd{}, args, context.Background(), &RootFlags{}); err == nil { + t.Fatalf("expected validation error for %v", args) + } + } +} diff --git a/internal/cmd/sheets_insert.go b/internal/cmd/sheets_insert.go index 093327736..bfaa893f3 100644 --- a/internal/cmd/sheets_insert.go +++ b/internal/cmd/sheets_insert.go @@ -98,16 +98,18 @@ func (c *SheetsInsertCmd) Run(ctx context.Context, flags *RootFlags) error { return usagef("unknown sheet %q", sheetName) } + dimRange := &sheets.DimensionRange{ + SheetId: sheetID, + Dimension: apiDimension, + StartIndex: startIndex, + EndIndex: endIndex, + } + forceSendDimensionRangeZeroes(dimRange) req := &sheets.BatchUpdateSpreadsheetRequest{ Requests: []*sheets.Request{ { InsertDimension: &sheets.InsertDimensionRequest{ - Range: &sheets.DimensionRange{ - SheetId: sheetID, - Dimension: apiDimension, - StartIndex: startIndex, - EndIndex: endIndex, - }, + Range: dimRange, InheritFromBefore: inheritFromBefore, }, }, diff --git a/internal/cmd/sheets_insert_test.go b/internal/cmd/sheets_insert_test.go index 96742ba76..355980bd8 100644 --- a/internal/cmd/sheets_insert_test.go +++ b/internal/cmd/sheets_insert_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -11,6 +12,31 @@ import ( "google.golang.org/api/sheets/v4" ) +func TestSheetsInsertCmd_FirstPositionSendsZeroes(t *testing.T) { + for _, sheetID := range []int64{0, 7} { + for _, dimension := range []string{"rows", "cols"} { + t.Run(fmt.Sprintf("%s/sheet%d", dimension, sheetID), func(t *testing.T) { + capture := &sheetsBatchUpdateCapture{} + svc := newSheetsBatchUpdateTestService(t, map[string]any{ + "sheets": []map[string]any{{"properties": map[string]any{"sheetId": sheetID, "title": "Data"}}}, + }, capture) + ctx := withSheetsTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc) + if err := runKong(t, &SheetsInsertCmd{}, []string{"s1", "Data", dimension, "1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) + } + requests := capture.Body["requests"].([]any) + insert := requests[0].(map[string]any)["insertDimension"].(map[string]any) + rng := insert["range"].(map[string]any) + for key, want := range map[string]float64{"sheetId": float64(sheetID), "startIndex": 0, "endIndex": 1} { + if got, ok := rng[key]; !ok || got != want { + t.Errorf("expected %s=%v in serialized range, got %#v", key, want, rng) + } + } + }) + } + } +} + func TestSheetsInsertCmd(t *testing.T) { var gotInsert *sheets.InsertDimensionRequest diff --git a/internal/cmd/sheets_raw_test.go b/internal/cmd/sheets_raw_test.go index 54acc51a8..e8893ae3d 100644 --- a/internal/cmd/sheets_raw_test.go +++ b/internal/cmd/sheets_raw_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "sync/atomic" "testing" @@ -100,6 +101,49 @@ func TestSheetsRaw_HappyPath_NoGridDataByDefault(t *testing.T) { } } +func TestSheetsRaw_SheetSelection(t *testing.T) { + for _, tc := range []struct { + name string + want string + }{ + {"Data", "'Data'"}, + {"A1", "'A1'"}, + {"O'Brien! Data", "'O''Brien! Data'"}, + {" Data ", "' Data '"}, + } { + for _, grid := range []bool{false, true} { + t.Run(tc.name+"/grid="+strconv.FormatBool(grid), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("ranges"); got != tc.want { + t.Errorf("ranges = %q, want %q", got, tc.want) + } + if got := r.URL.Query().Get("includeGridData") == "true"; got != grid { + t.Errorf("includeGridData = %v, want %v", got, grid) + } + _ = json.NewEncoder(w).Encode(fullSheetResponse("s1")) + })) + defer srv.Close() + var output bytes.Buffer + ctx := newSheetsRawTestContext(t, srv, &output, io.Discard) + args := []string{"s1", "--sheet", tc.name} + if grid { + args = append(args, "--include-grid-data") + } + if err := runKong(t, &SheetsRawCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(output.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["spreadsheetId"] != "s1" || got["properties"] == nil { + t.Fatalf("raw response metadata lost: %v", got) + } + }) + } + } +} + func TestSheetsRaw_IncludeGridDataFlag(t *testing.T) { hit := &sheetsRawHit{} srv := newSheetsRawTestServer(t, 0, fullSheetResponse("s1"), hit) diff --git a/internal/cmd/slides.go b/internal/cmd/slides.go index 670e4daff..e790028d2 100644 --- a/internal/cmd/slides.go +++ b/internal/cmd/slides.go @@ -39,6 +39,7 @@ type SlidesCmd struct { Table SlidesTableCmd `cmd:"" name:"table" help:"Create and update native tables"` Element SlidesElementCmd `cmd:"" name:"element" help:"Create and manipulate native page elements"` StyleText SlidesStyleTextCmd `cmd:"" name:"style-text" help:"Apply range-scoped text styling to one page element"` + ParagraphStyle SlidesParagraphStyleCmd `cmd:"" name:"paragraph-style" help:"Set paragraph alignment, spacing, indentation, or direction"` Link SlidesLinkCmd `cmd:"" name:"link" help:"Apply a hyperlink to a text range in one page element"` Bullets SlidesBulletsCmd `cmd:"" name:"bullets" help:"Turn paragraph bullets on or off in one page element"` ReplaceText SlidesReplaceTextCmd `cmd:"" name:"replace-text" help:"Find-and-replace text in an explicit object, slide, or presentation scope"` diff --git a/internal/cmd/slides_element.go b/internal/cmd/slides_element.go index ca0bee462..c720ad7bc 100644 --- a/internal/cmd/slides_element.go +++ b/internal/cmd/slides_element.go @@ -22,7 +22,7 @@ type SlidesElementCmd struct { Group SlidesElementGroupCmd `cmd:"" name:"group" help:"Group two or more elements"` Ungroup SlidesElementUngroupCmd `cmd:"" name:"ungroup" help:"Ungroup one or more element groups"` AltText SlidesElementAltTextCmd `cmd:"" name:"alt-text" help:"Set or clear element accessibility text"` - Delete SlidesElementDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete one page element"` + Delete SlidesElementDeleteCmd `cmd:"" name:"delete" aliases:"rm" help:"Delete one or more page elements"` } const ( @@ -395,26 +395,37 @@ func (c *SlidesElementAltTextCmd) Run(ctx context.Context, flags *RootFlags) err } type SlidesElementDeleteCmd struct { - PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` - ObjectID string `arg:"" name:"objectId" help:"Page element object ID"` + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + ObjectIDs []string `arg:"" name:"objectId" help:"One or more page element object IDs"` } func (c *SlidesElementDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { - presentationID, objectID, err := slidesElementTarget(c.PresentationID, c.ObjectID) + presentationID, objectIDs, err := slidesElementTargets(c.PresentationID, c.ObjectIDs, 1) if err != nil { return err } - request := &slides.Request{DeleteObject: &slides.DeleteObjectRequest{ObjectId: objectID}} - return runSlidesElementMutation(ctx, flags, slidesElementMutation{ + requests := make([]*slides.Request, 0, len(objectIDs)) + for _, objectID := range objectIDs { + requests = append(requests, &slides.Request{DeleteObject: &slides.DeleteObjectRequest{ObjectId: objectID}}) + } + mutation := slidesElementMutation{ Op: "slides.element.delete", - Action: "delete element", + Action: "delete elements", PresentationID: presentationID, - Request: request, - Payload: map[string]any{"object_id": objectID}, - Output: map[string]any{"presentationId": presentationID, "objectId": objectID, "deleted": true}, - Text: fmt.Sprintf("Deleted element %s", objectID), - Destructive: fmt.Sprintf("delete element %s from presentation %s", objectID, presentationID), - }) + Payload: map[string]any{"object_ids": objectIDs}, + Output: map[string]any{"presentationId": presentationID, "objectIds": objectIDs, "deleted": true}, + Text: fmt.Sprintf("Deleted %d elements", len(objectIDs)), + Destructive: fmt.Sprintf("delete elements %s from presentation %s", strings.Join(objectIDs, ", "), presentationID), + } + if len(objectIDs) == 1 { + objectID := objectIDs[0] + mutation.Action = "delete element" + mutation.Payload = map[string]any{"object_id": objectID} + mutation.Output = map[string]any{"presentationId": presentationID, "objectId": objectID, "deleted": true} + mutation.Text = fmt.Sprintf("Deleted element %s", objectID) + mutation.Destructive = fmt.Sprintf("delete element %s from presentation %s", objectID, presentationID) + } + return runSlidesElementBatchMutation(ctx, flags, mutation, &slides.BatchUpdatePresentationRequest{Requests: requests}) } type slidesElementMutation struct { @@ -430,6 +441,10 @@ type slidesElementMutation struct { func runSlidesElementMutation(ctx context.Context, flags *RootFlags, mutation slidesElementMutation) error { body := &slides.BatchUpdatePresentationRequest{Requests: []*slides.Request{mutation.Request}} + return runSlidesElementBatchMutation(ctx, flags, mutation, body) +} + +func runSlidesElementBatchMutation(ctx context.Context, flags *RootFlags, mutation slidesElementMutation, body *slides.BatchUpdatePresentationRequest) error { payload := mutation.Payload if payload == nil { payload = make(map[string]any) diff --git a/internal/cmd/slides_element_test.go b/internal/cmd/slides_element_test.go index 4ceb90a38..e1ca869a9 100644 --- a/internal/cmd/slides_element_test.go +++ b/internal/cmd/slides_element_test.go @@ -204,7 +204,7 @@ func TestSlidesElementStructuralRequests(t *testing.T) { t.Run("delete", func(t *testing.T) { request := captureSlidesElementRequest(t, &SlidesElementDeleteCmd{ PresentationID: "pres1", - ObjectID: "shape1", + ObjectIDs: []string{"shape1"}, }, &RootFlags{Account: "a@b.com", Force: true}) if request.DeleteObject == nil || request.DeleteObject.ObjectId != "shape1" { t.Fatalf("unexpected delete request: %+v", request) @@ -272,3 +272,64 @@ func TestSlidesElementValidation(t *testing.T) { }) } } + +func TestSlidesElementDeleteMultiple(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{}) + defer srv.Close() + var output bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &output, io.Discard), newSlidesServiceFromServer(t, srv)) + if err := runKong(t, &SlidesElementDeleteCmd{}, []string{"pres1", "shape1", "wordart1"}, ctx, &RootFlags{Account: "a@b.com", Force: true}); err != nil { + t.Fatal(err) + } + if len(captured) != 2 || captured[0].DeleteObject.ObjectId != "shape1" || captured[1].DeleteObject.ObjectId != "wordart1" { + t.Fatalf("unexpected delete batch: %+v", captured) + } + var result struct { + ObjectIDs []string `json:"objectIds"` + Deleted bool `json:"deleted"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if strings.Join(result.ObjectIDs, ",") != "shape1,wordart1" || !result.Deleted { + t.Fatalf("unexpected output: %s", output.String()) + } +} + +func TestSlidesElementDeleteSingleOutput(t *testing.T) { + var captured []*slides.Request + srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{}) + defer srv.Close() + var output bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &output, io.Discard), newSlidesServiceFromServer(t, srv)) + if err := runKong(t, &SlidesElementDeleteCmd{}, []string{"pres1", "shape1"}, ctx, &RootFlags{Account: "a@b.com", Force: true}); err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["objectId"] != "shape1" || result["deleted"] != true || result["objectIds"] != nil { + t.Fatalf("single-ID contract changed: %s", output.String()) + } +} + +func TestSlidesElementDeleteGuards(t *testing.T) { + ctx := withSlidesTestServiceFactory(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), func(context.Context, string) (*slides.Service, error) { + t.Fatal("service must not be created") + return nil, context.Canceled + }) + for _, args := range [][]string{{"pres1", "shape1", "shape1"}, {"pres1", "shape1", ""}} { + if err := runKong(t, &SlidesElementDeleteCmd{}, args, ctx, &RootFlags{Force: true}); err == nil { + t.Fatalf("expected invalid IDs to fail: %v", args) + } + } + args := []string{"pres1", "shape1", "shape2"} + if err := runKong(t, &SlidesElementDeleteCmd{}, args, ctx, &RootFlags{NoInput: true}); err == nil { + t.Fatal("expected confirmation refusal") + } + if err := runKong(t, &SlidesElementDeleteCmd{}, args, ctx, &RootFlags{DryRun: true, NoInput: true}); err != nil && ExitCode(err) != 0 { + t.Fatal(err) + } +} diff --git a/internal/cmd/slides_insert_text.go b/internal/cmd/slides_insert_text.go index 4408f3fcd..a2959e2fc 100644 --- a/internal/cmd/slides_insert_text.go +++ b/internal/cmd/slides_insert_text.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "slices" "strings" "google.golang.org/api/slides/v1" @@ -14,13 +15,13 @@ import ( // SlidesInsertTextCmd inserts text into an existing text-capable page element. // It is a thin wrapper around presentations.batchUpdate with an InsertTextRequest -// (optionally preceded by a DeleteText request when --replace is set). +// (with a style-preserving replacement batch when --replace is set). type SlidesInsertTextCmd struct { PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` ObjectID string `arg:"" name:"objectId" help:"Page element object ID (shape or table) to insert text into"` Text string `arg:"" name:"text" help:"Text to insert (use '-' to read from stdin)"` InsertionIndex int64 `name:"insertion-index" help:"Zero-based index where text is inserted within the element's existing text" default:"0"` - Replace bool `name:"replace" help:"Clear existing text in the element before inserting (emits DeleteText + InsertText in the same batch)"` + Replace bool `name:"replace" help:"Replace existing text while inheriting its leading text style (revision-checked atomic batch)"` Row *int64 `name:"row" help:"0-based table row index for cell-targeted text; requires --col"` Col *int64 `name:"col" help:"0-based table column index for cell-targeted text; requires --row"` } @@ -70,7 +71,7 @@ func (c *SlidesInsertTextCmd) Run(ctx context.Context, flags *RootFlags) error { // Build the batchUpdate request body. var requests []*slides.Request if c.Replace { - requests = buildSlidesClearAndInsertTextRequestsAt(objectID, text, cellLocation) + requests = buildSlidesInsertTextReplacement(objectID, text, true, cellLocation) } else { requests = append(requests, &slides.Request{ InsertText: &slides.InsertTextRequest{ @@ -107,16 +108,34 @@ func (c *SlidesInsertTextCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - if c.Replace && cellLocation != nil { + if c.Replace { pres, getErr := slidesSvc.Presentations.Get(presentationID).Context(ctx).Do() if getErr != nil { return fmt.Errorf("get presentation: %w", getErr) } - found, hasExistingText := slidesTableCellTextState(pres, objectID, *c.Row, *c.Col) + pages := slices.Concat(pres.Slides, pres.Layouts, pres.Masters) + pages = append(pages, pres.NotesMaster) + for _, page := range pres.Slides { + if page != nil && page.SlideProperties != nil { + pages = append(pages, page.SlideProperties.NotesPage) + } + } + lookup := &slides.Presentation{Slides: pages} + var found, hasExistingText bool + if cellLocation != nil { + found, hasExistingText = slidesTableCellTextState(lookup, objectID, *c.Row, *c.Col) + } else { + var content *slides.TextContent + content, found, _ = slidesShapeTextContentByObjectID(lookup, objectID) + hasExistingText = slidesTextContentHasDeletableText(content) + } if !found { - return fmt.Errorf("table cell %s[%d,%d] not found", objectID, *c.Row, *c.Col) + if cellLocation != nil { + return fmt.Errorf("table cell %s[%d,%d] not found", objectID, *c.Row, *c.Col) + } + return fmt.Errorf("object %s not found", objectID) } - body.Requests = buildSlidesReplaceTextRequestsAt(objectID, text, hasExistingText, cellLocation) + body.Requests = buildSlidesInsertTextReplacement(objectID, text, hasExistingText, cellLocation) if pres.RevisionId != "" { body.WriteControl = &slides.WriteControl{RequiredRevisionId: pres.RevisionId} } @@ -144,6 +163,27 @@ func (c *SlidesInsertTextCmd) Run(ctx context.Context, flags *RootFlags) error { return writeSlidesInsertTextResult(ctx, u, presentationID, revisionID, replies) } +func buildSlidesInsertTextReplacement(objectID, text string, hasExistingText bool, cell *slides.TableCellLocation) []*slides.Request { + // Slides strips these characters before insertion; offsets must count the + // normalized UTF-16 text or suffix deletion can remove the wrong characters. + text = strings.Map(func(r rune) rune { + if r <= 0x08 || (r >= 0x0c && r <= 0x1f) || (r >= 0xe000 && r <= 0xf8ff) { + return -1 + } + return r + }, text) + if text == "" || !hasExistingText { + return buildSlidesReplaceTextRequestsAt(objectID, text, hasExistingText, cell) + } + // Inserting before deletion inherits the visible leading run instead of the + // final newline's style, which can differ in copied templates. + start := utf16Len(text) + return []*slides.Request{ + {InsertText: &slides.InsertTextRequest{ObjectId: objectID, CellLocation: cell, Text: text}}, + {DeleteText: &slides.DeleteTextRequest{ObjectId: objectID, CellLocation: cell, TextRange: &slides.Range{Type: "FROM_START_INDEX", StartIndex: &start}}}, + } +} + func writeSlidesInsertTextResult(ctx context.Context, u *ui.UI, presentationID, revisionID string, replies int) error { if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ diff --git a/internal/cmd/slides_insert_text_test.go b/internal/cmd/slides_insert_text_test.go index 2560ccc85..71add52b8 100644 --- a/internal/cmd/slides_insert_text_test.go +++ b/internal/cmd/slides_insert_text_test.go @@ -152,40 +152,28 @@ func TestSlidesInsertText(t *testing.T) { } } -func TestSlidesInsertText_ReplaceEmitsDeleteThenInsert(t *testing.T) { - var captured []*slides.Request - srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ - "presentationId": "pres1", - "replies": []any{map[string]any{}, map[string]any{}}, - }) +func TestSlidesInsertText_ReplaceInheritsLeadingStyle(t *testing.T) { + var captured slides.BatchUpdatePresentationRequest + pres := slidesPresentationWithShapeText("old\n") + srv := mockSlidesPresentationBatchUpdateServer(t, &captured, pres, map[string]any{}) defer srv.Close() - - svc := newSlidesServiceFromServer(t, srv) - flags := &RootFlags{Account: "a@b.com"} - ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), svc) - - cmd := &SlidesInsertTextCmd{ - PresentationID: "pres1", - ObjectID: "shape_1", - Text: "replacement", - Replace: true, + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), newSlidesServiceFromServer(t, srv)) + cmd := &SlidesInsertTextCmd{PresentationID: "pres1", ObjectID: "shape_1", Text: "replacement", Replace: true} + if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) } - if err := cmd.Run(ctx, flags); err != nil && ExitCode(err) != 0 { - t.Fatalf("Run: %v", err) + if len(captured.Requests) != 2 || captured.Requests[0].InsertText == nil || captured.Requests[1].DeleteText == nil { + t.Fatalf("expected insert then delete: %+v", captured.Requests) } - - if len(captured) != 2 { - t.Fatalf("expected 2 requests (DeleteText + InsertText), got %d", len(captured)) + if captured.Requests[0].InsertText.Text != "replacement" { + t.Fatal("wrong replacement") } - if captured[0].DeleteText == nil { - t.Error("expected first request to be DeleteText") - } else if captured[0].DeleteText.TextRange == nil || captured[0].DeleteText.TextRange.Type != "ALL" { - t.Errorf("expected DeleteText TextRange.Type=ALL, got %+v", captured[0].DeleteText.TextRange) + span := captured.Requests[1].DeleteText.TextRange + if span.Type != "FROM_START_INDEX" || span.StartIndex == nil || *span.StartIndex != 11 { + t.Fatalf("wrong suffix deletion: %+v", span) } - if captured[1].InsertText == nil { - t.Error("expected second request to be InsertText") - } else if captured[1].InsertText.Text != "replacement" { - t.Errorf("expected inserted text %q, got %q", "replacement", captured[1].InsertText.Text) + if captured.WriteControl == nil || captured.WriteControl.RequiredRevisionId != "rev1" { + t.Fatal("missing revision guard") } } @@ -262,17 +250,17 @@ func TestSlidesInsertText_CellReplaceEmitsCellDeleteThenInsert(t *testing.T) { if captured.WriteControl == nil || captured.WriteControl.RequiredRevisionId != "rev1" { t.Fatalf("write control = %+v, want required revision rev1", captured.WriteControl) } - if captured.Requests[0].DeleteText == nil || captured.Requests[0].DeleteText.CellLocation == nil { - t.Fatalf("expected cell-targeted DeleteText, got %+v", captured.Requests[0]) + if captured.Requests[1].DeleteText == nil || captured.Requests[1].DeleteText.CellLocation == nil { + t.Fatalf("expected cell-targeted DeleteText, got %+v", captured.Requests[1]) } - if captured.Requests[0].DeleteText.CellLocation.RowIndex != 1 || captured.Requests[0].DeleteText.CellLocation.ColumnIndex != 0 { - t.Fatalf("unexpected delete cell location: %+v", captured.Requests[0].DeleteText.CellLocation) + if captured.Requests[1].DeleteText.CellLocation.RowIndex != 1 || captured.Requests[1].DeleteText.CellLocation.ColumnIndex != 0 { + t.Fatalf("unexpected delete cell location: %+v", captured.Requests[1].DeleteText.CellLocation) } - if captured.Requests[1].InsertText == nil || captured.Requests[1].InsertText.CellLocation == nil { - t.Fatalf("expected cell-targeted InsertText, got %+v", captured.Requests[1]) + if captured.Requests[0].InsertText == nil || captured.Requests[0].InsertText.CellLocation == nil { + t.Fatalf("expected cell-targeted InsertText, got %+v", captured.Requests[0]) } - if captured.Requests[1].InsertText.CellLocation.RowIndex != 1 || captured.Requests[1].InsertText.CellLocation.ColumnIndex != 0 { - t.Fatalf("unexpected insert cell location: %+v", captured.Requests[1].InsertText.CellLocation) + if captured.Requests[0].InsertText.CellLocation.RowIndex != 1 || captured.Requests[0].InsertText.CellLocation.ColumnIndex != 0 { + t.Fatalf("unexpected insert cell location: %+v", captured.Requests[0].InsertText.CellLocation) } } @@ -376,8 +364,8 @@ func TestSlidesInsertText_CellReplaceRejectsMissingCell(t *testing.T) { } func TestSlidesInsertText_ReplaceEmptyClearsOnly(t *testing.T) { - var captured []*slides.Request - srv := mockSlidesBatchUpdateServer(t, &captured, map[string]any{ + var body slides.BatchUpdatePresentationRequest + srv := mockSlidesPresentationBatchUpdateServer(t, &body, slidesPresentationWithShapeText("old\n"), map[string]any{ "presentationId": "pres1", "replies": []any{map[string]any{}}, }) @@ -397,6 +385,7 @@ func TestSlidesInsertText_ReplaceEmptyClearsOnly(t *testing.T) { t.Fatalf("Run: %v", err) } + captured := body.Requests if len(captured) != 1 { t.Fatalf("expected 1 DeleteText request, got %d", len(captured)) } @@ -474,8 +463,8 @@ func TestSlidesInsertText_DryRunNoAPICall(t *testing.T) { if len(body.Requests) != 2 { t.Fatalf("expected 2 requests in dry-run body, got %d", len(body.Requests)) } - if body.Requests[0].DeleteText == nil || body.Requests[1].InsertText == nil { - t.Errorf("expected DeleteText then InsertText in dry-run body, got %+v", body.Requests) + if body.Requests[0].InsertText == nil || body.Requests[1].DeleteText == nil { + t.Errorf("expected InsertText then DeleteText in dry-run body, got %+v", body.Requests) } } @@ -611,3 +600,82 @@ func TestSlidesInsertText_EmptyObjectID(t *testing.T) { t.Fatalf("expected empty objectId error, got: %v", err) } } + +func slidesPresentationWithShapeText(text string) *slides.Presentation { + return &slides.Presentation{RevisionId: "rev1", Slides: []*slides.Page{{PageElements: []*slides.PageElement{{ObjectId: "shape_1", Shape: &slides.Shape{Text: &slides.TextContent{TextElements: []*slides.TextElement{{TextRun: &slides.TextRun{Content: text}}}}}}}}}} +} + +func TestSlidesInsertTextReplacementBoundaries(t *testing.T) { + for _, tc := range []struct { + name, text, want string + existing bool + requests int + }{ + {"unicode", "😀\tA\x01B\rC\ue000D\nZ", "😀\tABCD\nZ", true, 2}, + {"stripped", "\x01\r\ue000", "", true, 1}, + {"empty", "", "", true, 1}, + {"new", "😀 first", "😀 first", false, 1}, + {"empty-noop", "", "", false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + cell := slidesTableCellLocation(0, 1) + reqs := buildSlidesInsertTextReplacement("target", tc.text, tc.existing, cell) + if len(reqs) != tc.requests { + t.Fatalf("requests=%+v", reqs) + } + if tc.want != "" { + if reqs[0].InsertText == nil || reqs[0].InsertText.Text != tc.want || reqs[0].InsertText.CellLocation != cell { + t.Fatalf("insert=%+v", reqs[0]) + } + } + if len(reqs) == 2 { + span := reqs[1].DeleteText.TextRange + if span.StartIndex == nil || *span.StartIndex != utf16Len(tc.want) || span.Type != "FROM_START_INDEX" { + t.Fatalf("delete=%+v", span) + } + } + }) + } +} + +func TestSlidesInsertText_EmptyShapeAndPageKinds(t *testing.T) { + for _, location := range []string{"slide", "layout", "master", "notes"} { + t.Run(location, func(t *testing.T) { + var body slides.BatchUpdatePresentationRequest + pres := slidesPresentationWithShapeText("\n") + switch location { + case "layout": + pres.Layouts = pres.Slides + pres.Slides = nil + case "master": + pres.Masters = pres.Slides + pres.Slides = nil + case "notes": + pres.NotesMaster = pres.Slides[0] + pres.Slides = nil + } + srv := mockSlidesPresentationBatchUpdateServer(t, &body, pres, map[string]any{}) + defer srv.Close() + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), newSlidesServiceFromServer(t, srv)) + err := (&SlidesInsertTextCmd{PresentationID: "pres1", ObjectID: "shape_1", Text: "first", Replace: true}).Run(ctx, &RootFlags{Account: "a@b.com"}) + if err != nil { + t.Fatal(err) + } + if len(body.Requests) != 1 || body.Requests[0].InsertText == nil { + t.Fatalf("empty shape requires insert only: %+v", body.Requests) + } + }) + } +} + +func TestSlidesTextContentBlankParagraphs(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{{"", false}, {"\n", false}, {"\n\n", true}, {"x", true}, {"x\n", true}} { + pres := slidesPresentationWithShapeText(tc.text) + if got := slidesTextContentHasDeletableText(pres.Slides[0].PageElements[0].Shape.Text); got != tc.want { + t.Fatalf("text %q: got %v", tc.text, got) + } + } +} diff --git a/internal/cmd/slides_paragraph_style.go b/internal/cmd/slides_paragraph_style.go new file mode 100644 index 000000000..85c2e50ce --- /dev/null +++ b/internal/cmd/slides_paragraph_style.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "context" + "fmt" + "math" + "strings" + + "google.golang.org/api/slides/v1" +) + +type SlidesParagraphStyleCmd struct { + PresentationID string `arg:"" name:"presentationId" help:"Presentation ID"` + ObjectID string `arg:"" name:"objectId" help:"Shape or table object ID"` + Range string `name:"range" help:"UTF-16 range as start:end (default: all text); styles every intersecting paragraph"` + Row *int64 `name:"row" help:"Zero-based table row; requires --col"` + Col *int64 `name:"col" help:"Zero-based table column; requires --row"` + Align string `name:"align" help:"Paragraph alignment: START, CENTER, END, JUSTIFIED"` + Direction string `name:"direction" help:"Text direction: LEFT_TO_RIGHT or RIGHT_TO_LEFT"` + LineSpacing *float64 `name:"line-spacing" help:"Line spacing percent (100 is normal)"` + SpaceAbove *float64 `name:"space-above" help:"Space above each paragraph in points"` + SpaceBelow *float64 `name:"space-below" help:"Space below each paragraph in points"` + IndentStart *float64 `name:"indent-start" help:"Paragraph start indentation in points"` + IndentEnd *float64 `name:"indent-end" help:"Paragraph end indentation in points"` + IndentFirstLine *float64 `name:"indent-first-line" help:"First-line indentation in points"` +} + +func (c *SlidesParagraphStyleCmd) Run(ctx context.Context, flags *RootFlags) error { + presentationID, objectID, err := slidesElementTarget(c.PresentationID, c.ObjectID) + if err != nil { + return err + } + if (c.Row == nil) != (c.Col == nil) { + return usage("--row and --col must be provided together") + } + if c.Row != nil && (*c.Row < 0 || *c.Col < 0) { + return usage("--row and --col must be >= 0") + } + textRange := &slides.Range{Type: "ALL"} + if strings.TrimSpace(c.Range) != "" { + textRange, err = parseSlidesTextRange(c.Range) + if err != nil { + return err + } + } + style, fields, err := c.paragraphStyle() + if err != nil { + return err + } + request := &slides.Request{UpdateParagraphStyle: &slides.UpdateParagraphStyleRequest{ + ObjectId: objectID, TextRange: textRange, Style: style, Fields: strings.Join(fields, ","), + }} + payload := map[string]any{"object_id": objectID, "fields": fields} + output := map[string]any{"presentationId": presentationID, "objectId": objectID, "fields": fields} + message := fmt.Sprintf("Styled paragraphs in %s", objectID) + if c.Row != nil { + request.UpdateParagraphStyle.CellLocation = slidesTableCellLocation(*c.Row, *c.Col) + payload["row"], payload["col"] = *c.Row, *c.Col + output["row"], output["col"] = *c.Row, *c.Col + return runSlidesTableMutation(ctx, flags, slidesTableMutation{ + Op: "slides.paragraph-style", Action: "style paragraphs", PresentationID: presentationID, + TableObjectID: objectID, Request: request, Payload: payload, Output: output, Text: message, + Validate: func(table *slides.Table) error { return validateSlidesTableAnchor(table, *c.Row, *c.Col) }, + }) + } + return runSlidesElementMutation(ctx, flags, slidesElementMutation{ + Op: "slides.paragraph-style", Action: "style paragraphs", PresentationID: presentationID, + Request: request, Payload: payload, Output: output, Text: message, + }) +} + +func (c *SlidesParagraphStyleCmd) paragraphStyle() (*slides.ParagraphStyle, []string, error) { + style := &slides.ParagraphStyle{} + var fields []string + if c.Align != "" { + align, err := slidesElementEnum(c.Align, "", "START", "CENTER", "END", "JUSTIFIED") + if err != nil { + return nil, nil, usage("--align must be START, CENTER, END, or JUSTIFIED") + } + style.Alignment = align + fields = append(fields, "alignment") + } + if c.Direction != "" { + direction, err := slidesElementEnum(c.Direction, "", "LEFT_TO_RIGHT", "RIGHT_TO_LEFT") + if err != nil { + return nil, nil, usage("--direction must be LEFT_TO_RIGHT or RIGHT_TO_LEFT") + } + style.Direction = direction + fields = append(fields, "direction") + } + if c.LineSpacing != nil { + if math.IsNaN(*c.LineSpacing) || math.IsInf(*c.LineSpacing, 0) || *c.LineSpacing <= 0 { + return nil, nil, usage("--line-spacing must be finite and > 0") + } + style.LineSpacing = *c.LineSpacing + fields = append(fields, "lineSpacing") + } + for _, dimension := range []struct { + value *float64 + target **slides.Dimension + field string + nonnegative bool + }{ + {c.SpaceAbove, &style.SpaceAbove, "spaceAbove", true}, + {c.SpaceBelow, &style.SpaceBelow, "spaceBelow", true}, + {c.IndentStart, &style.IndentStart, "indentStart", false}, + {c.IndentEnd, &style.IndentEnd, "indentEnd", false}, + {c.IndentFirstLine, &style.IndentFirstLine, "indentFirstLine", false}, + } { + if dimension.value == nil { + continue + } + value := *dimension.value + if math.IsNaN(value) || math.IsInf(value, 0) || (dimension.nonnegative && value < 0) { + return nil, nil, usagef("invalid %s value", dimension.field) + } + *dimension.target = &slides.Dimension{Magnitude: value, Unit: "PT", ForceSendFields: []string{"Magnitude"}} + fields = append(fields, dimension.field) + } + if len(fields) == 0 { + return nil, nil, usage("provide at least one paragraph style option") + } + return style, fields, nil +} diff --git a/internal/cmd/slides_paragraph_style_test.go b/internal/cmd/slides_paragraph_style_test.go new file mode 100644 index 000000000..a4e20442a --- /dev/null +++ b/internal/cmd/slides_paragraph_style_test.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "io" + "math" + "strings" + "testing" + + "google.golang.org/api/slides/v1" +) + +func TestSlidesParagraphStyleShape(t *testing.T) { + var captured []*slides.Request + server := mockSlidesBatchUpdateServer(t, &captured, map[string]any{}) + defer server.Close() + var output bytes.Buffer + ctx := withSlidesTestService(newCmdRuntimeJSONOutputContext(t, &output, io.Discard), newSlidesServiceFromServer(t, server)) + args := []string{"pres1", "shape1", "--align", "center", "--direction", "right-to-left", "--line-spacing", "125", "--space-above", "0", "--indent-start", "18", "--indent-first-line", "0", "--range", "0:8"} + if err := runKong(t, &SlidesParagraphStyleCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) + } + if len(captured) != 1 || captured[0].UpdateParagraphStyle == nil { + t.Fatalf("requests=%+v", captured) + } + r := captured[0].UpdateParagraphStyle + if r.Style.Alignment != "CENTER" || r.Style.Direction != "RIGHT_TO_LEFT" || r.Style.LineSpacing != 125 { + t.Fatalf("style=%+v", r.Style) + } + if r.Fields != "alignment,direction,lineSpacing,spaceAbove,indentStart,indentFirstLine" { + t.Fatalf("fields=%s", r.Fields) + } + if r.TextRange.Type != "FIXED_RANGE" || r.TextRange.StartIndex == nil || *r.TextRange.StartIndex != 0 || r.TextRange.EndIndex == nil || *r.TextRange.EndIndex != 8 { + t.Fatalf("range=%+v", r.TextRange) + } + if !strings.Contains(output.String(), `"objectId": "shape1"`) { + t.Fatal(output.String()) + } +} + +func TestSlidesParagraphStyleZeroFields(t *testing.T) { + zero := float64(0) + c := SlidesParagraphStyleCmd{SpaceAbove: &zero, SpaceBelow: &zero, IndentStart: &zero, IndentEnd: &zero, IndentFirstLine: &zero} + style, fields, err := c.paragraphStyle() + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(style) + if err != nil { + t.Fatal(err) + } + var obj map[string]map[string]any + if err := json.Unmarshal(raw, &obj); err != nil { + t.Fatal(err) + } + if len(fields) != 5 { + t.Fatal(fields) + } + for _, field := range fields { + if v, ok := obj[field]["magnitude"]; !ok || v != float64(0) { + t.Fatalf("explicit zero missing: %s", raw) + } + } +} + +func TestSlidesParagraphStyleCell(t *testing.T) { + var captured slides.BatchUpdatePresentationRequest + pres := slidesPresentationWithTableCellText(0, 0, "cell\n") + server := mockSlidesPresentationBatchUpdateServer(t, &captured, pres, map[string]any{}) + defer server.Close() + ctx := withSlidesTestService(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), newSlidesServiceFromServer(t, server)) + args := []string{"pres1", "table_1", "--row", "0", "--col", "0", "--align", "END"} + if err := runKong(t, &SlidesParagraphStyleCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatal(err) + } + r := captured.Requests[0].UpdateParagraphStyle + if r.CellLocation == nil || r.CellLocation.RowIndex != 0 || r.CellLocation.ColumnIndex != 0 || r.TextRange.Type != "ALL" { + t.Fatalf("request=%+v", r) + } + if captured.WriteControl == nil || captured.WriteControl.RequiredRevisionId != "rev1" { + t.Fatal("missing revision protection") + } + captured = slides.BatchUpdatePresentationRequest{} + args = []string{"pres1", "table_1", "--row", "9", "--col", "0", "--align", "END"} + if err := runKong(t, &SlidesParagraphStyleCmd{}, args, ctx, &RootFlags{Account: "a@b.com"}); err == nil || len(captured.Requests) != 0 { + t.Fatalf("invalid cell changed table: err=%v", err) + } +} + +func TestSlidesParagraphStyleValidationAndDryRun(t *testing.T) { + ctx := withSlidesTestServiceFactory(newCmdRuntimeOutputContext(t, io.Discard, io.Discard), func(context.Context, string) (*slides.Service, error) { + t.Fatal("service created") + return nil, context.Canceled + }) + for _, opts := range [][]string{{}, {"--align", "bad"}, {"--direction", "bad"}, {"--line-spacing", "0"}, {"--space-above", "-1"}, {"--row", "0", "--align", "START"}, {"--row", "-1", "--col", "0", "--align", "START"}, {"--range", "8:0", "--align", "START"}} { + args := append([]string{"pres1", "shape1"}, opts...) + if err := runKong(t, &SlidesParagraphStyleCmd{}, args, ctx, &RootFlags{}); err == nil { + t.Fatalf("accepted invalid options %v", opts) + } + } + nan := math.NaN() + if _, _, err := (&SlidesParagraphStyleCmd{IndentStart: &nan}).paragraphStyle(); err == nil { + t.Fatal("accepted NaN") + } + if err := runKong(t, &SlidesParagraphStyleCmd{}, []string{"pres1", "shape1", "--align", "START"}, ctx, &RootFlags{DryRun: true}); err != nil && ExitCode(err) != 0 { + t.Fatal(err) + } +} + +func TestSlidesParagraphStyleDryRunShowsValuesAndRange(t *testing.T) { + for _, tc := range []struct { + align, span, rangeType string + cell bool + }{{"START", "", "ALL", false}, {"END", "0:8", "FIXED_RANGE", true}} { + t.Run(tc.align, func(t *testing.T) { + var output bytes.Buffer + ctx := newCmdRuntimeJSONOutputContext(t, &output, io.Discard) + args := []string{"pres1", "target", "--align", tc.align, "--space-above", "0"} + if tc.span != "" { + args = append(args, "--range", tc.span) + } + if tc.cell { + args = append(args, "--row", "0", "--col", "0") + } + err := runKong(t, &SlidesParagraphStyleCmd{}, args, ctx, &RootFlags{DryRun: true}) + if err != nil && ExitCode(err) != 0 { + t.Fatal(err) + } + var result struct { + Request struct { + BatchUpdate slides.BatchUpdatePresentationRequest `json:"batch_update"` + } `json:"request"` + } + if err := json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result.Request.BatchUpdate.Requests) != 1 { + t.Fatalf("missing dry-run requests: %s", output.String()) + } + request := result.Request.BatchUpdate.Requests[0].UpdateParagraphStyle + if request == nil || request.Style.Alignment != tc.align || request.TextRange.Type != tc.rangeType || request.Style.SpaceAbove == nil { + t.Fatalf("incomplete plan: %s", output.String()) + } + if tc.cell && request.CellLocation == nil { + t.Fatal("missing cell target") + } + }) + } +} diff --git a/internal/cmd/slides_shared.go b/internal/cmd/slides_shared.go index e0c9e2b9b..98236438b 100644 --- a/internal/cmd/slides_shared.go +++ b/internal/cmd/slides_shared.go @@ -226,18 +226,19 @@ func slidesTextContentHasDeletableText(content *slides.TextContent) bool { if content == nil { return false } + var text strings.Builder for _, textElement := range content.TextElements { if textElement == nil { continue } - if textElement.TextRun != nil && strings.TrimRight(textElement.TextRun.Content, "\r\n") != "" { - return true + if textElement.TextRun != nil { + text.WriteString(textElement.TextRun.Content) } - if textElement.AutoText != nil && strings.TrimRight(textElement.AutoText.Content, "\r\n") != "" { - return true + if textElement.AutoText != nil { + text.WriteString(textElement.AutoText.Content) } } - return false + return strings.TrimSuffix(strings.TrimSuffix(text.String(), "\n"), "\r") != "" } func buildSlidesReplaceTextRequests(objectID string, text string, hasExistingText bool) []*slides.Request { @@ -269,10 +270,6 @@ func buildSlidesReplaceTextRequestsAt(objectID string, text string, hasExistingT return requests } -func buildSlidesClearAndInsertTextRequestsAt(objectID string, text string, cell *slides.TableCellLocation) []*slides.Request { - return buildSlidesReplaceTextRequestsAt(objectID, text, true, cell) -} - func slidesTableCellLocation(row, col int64) *slides.TableCellLocation { return &slides.TableCellLocation{ RowIndex: row, diff --git a/internal/discoveryapi/cache.go b/internal/discoveryapi/cache.go new file mode 100644 index 000000000..ab183d93f --- /dev/null +++ b/internal/discoveryapi/cache.go @@ -0,0 +1,164 @@ +package discoveryapi + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + discovery "google.golang.org/api/discovery/v1" + + "github.com/openclaw/gogcli/internal/filelock" +) + +const ( + descriptionCacheTTL = 24 * time.Hour + descriptionCacheMaxEntries = 32 + descriptionCacheMaxBytes = 64 << 20 + descriptionCacheMaxEntryBytes = 16 << 20 + descriptionCacheTempPrefix = ".discovery-" +) + +func (c Client) descriptionCacheKey(requestURL string) string { + // Explicit base URLs must not reuse service-hosted fallback results. + if strings.TrimSpace(c.BaseURL) == "" { + requestURL += "\x00service-hosted-fallback" + } + sum := sha256.Sum256([]byte(requestURL)) + + return hex.EncodeToString(sum[:]) + ".json" +} + +func (c Client) cachedDescription(key string) *discovery.RestDescription { + path := filepath.Join(c.CacheDir, key) + + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > descriptionCacheMaxEntryBytes { + return nil + } + + age := time.Since(info.ModTime()) + if age < 0 || age >= descriptionCacheTTL { + return nil + } + + f, err := os.Open(path) // #nosec G304 -- key is a generated SHA-256 filename in the configured cache directory. + if err != nil { + return nil + } + defer f.Close() + + raw, err := io.ReadAll(io.LimitReader(f, descriptionCacheMaxEntryBytes+1)) + if err != nil || len(raw) > descriptionCacheMaxEntryBytes { + return nil + } + + var description discovery.RestDescription + if json.Unmarshal(raw, &description) != nil { + return nil + } + + return &description +} + +func (c Client) cacheDescription(key string, raw []byte) { + if c.CacheDir == "" || len(raw) > descriptionCacheMaxEntryBytes { + return + } + + if os.MkdirAll(c.CacheDir, 0o700) != nil { + return + } + // Serialize pruning and replacement across CLI processes; never hold a lock + // over the network, and treat contention or filesystem errors as cache misses. + _ = filelock.Shared(filepath.Join(c.CacheDir, ".lock"), 100*time.Millisecond).WithExclusive(func() error { + if !pruneDescriptionCache(c.CacheDir, key, int64(len(raw))) { + return nil + } + + f, err := os.CreateTemp(c.CacheDir, descriptionCacheTempPrefix) + if err != nil { + return nil + } + defer os.Remove(f.Name()) + + if _, err := f.Write(raw); err != nil { + _ = f.Close() + return nil + } + + if err := f.Close(); err != nil { + return nil + } + _ = os.Rename(f.Name(), filepath.Join(c.CacheDir, key)) + + return nil + }) +} + +func pruneDescriptionCache(dir, replacing string, incoming int64) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + type record struct { + name string + info os.FileInfo + } + var records []record + total := incoming + + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, descriptionCacheTempPrefix) && entry.Type().IsRegular() { + if err := os.Remove(filepath.Join(dir, name)); err != nil { + return false + } + + continue + } + + if name == replacing || !isDescriptionCacheName(name) || !entry.Type().IsRegular() { + continue + } + + info, err := entry.Info() + if err != nil { + return false + } + total += info.Size() + records = append(records, record{name: name, info: info}) + } + + sort.Slice(records, func(i, j int) bool { return records[i].info.ModTime().Before(records[j].info.ModTime()) }) + + count := len(records) + 1 + for _, record := range records { + age := time.Since(record.info.ModTime()) + if age >= 0 && age < descriptionCacheTTL && count <= descriptionCacheMaxEntries && total <= descriptionCacheMaxBytes { + continue + } + + if err := os.Remove(filepath.Join(dir, record.name)); err != nil { + return false + } + total -= record.info.Size() + count-- + } + + return true +} + +func isDescriptionCacheName(name string) bool { + if len(name) != sha256.Size*2+len(".json") || !strings.HasSuffix(name, ".json") { + return false + } + _, err := hex.DecodeString(strings.TrimSuffix(name, ".json")) + + return err == nil +} diff --git a/internal/discoveryapi/cache_test.go b/internal/discoveryapi/cache_test.go new file mode 100644 index 000000000..4c3c11c95 --- /dev/null +++ b/internal/discoveryapi/cache_test.go @@ -0,0 +1,289 @@ +package discoveryapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestDescriptionCacheLifecycle(t *testing.T) { + var requests atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = fmt.Fprint(w, `{"name":"demo","version":"v1"}`) + })) + defer server.Close() + client := Client{BaseURL: server.URL, CacheDir: t.TempDir()} + load := func() { + t.Helper() + + d, err := client.Description(context.Background(), "demo", "v1") + if err != nil || d.Name != "demo" { + t.Fatalf("description=%v err=%v", d, err) + } + } + load() + load() + + if requests.Load() != 1 { + t.Fatalf("warm cache fetched %d times", requests.Load()) + } + file := filepath.Join(client.CacheDir, client.descriptionCacheKey(server.URL+"/apis/demo/v1/rest")) + + for _, mode := range []string{"expired", "future", "corrupt"} { + t.Run(mode, func(t *testing.T) { + switch mode { + case "expired": + old := time.Now().Add(-descriptionCacheTTL - time.Hour) + if err := os.Chtimes(file, old, old); err != nil { + t.Fatal(err) + } + case "future": + future := time.Now().Add(time.Hour) + if err := os.Chtimes(file, future, future); err != nil { + t.Fatal(err) + } + case "corrupt": + if err := os.WriteFile(file, []byte("not JSON"), 0o600); err != nil { + t.Fatal(err) + } + } + before := requests.Load() + + load() + load() + + if requests.Load() != before+1 { + t.Fatal("expected one refresh") + } + }) + } + uncached := client + uncached.CacheDir = "" + before := requests.Load() + + for range 2 { + if _, err := uncached.Description(context.Background(), "demo", "v1"); err != nil { + t.Fatal(err) + } + } + + if requests.Load() != before+2 { + t.Fatal("uncached path did not fetch each request") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := client.Description(ctx, "demo", "v1"); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled cache lookup=%v", err) + } +} + +func TestDescriptionCacheDoesNotStoreFailures(t *testing.T) { + for _, tc := range []struct { + status int + body string + }{{500, `{"error":"fail"}`}, {200, "invalid"}} { + t.Run(fmt.Sprint(tc.status), func(t *testing.T) { + var count atomic.Int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count.Add(1) + w.WriteHeader(tc.status) + _, _ = fmt.Fprint(w, tc.body) + })) + defer server.Close() + + client := Client{BaseURL: server.URL, CacheDir: t.TempDir()} + for range 2 { + if _, err := client.Description(context.Background(), "demo", "v1"); err == nil { + t.Fatal("expected failure") + } + } + + if count.Load() != 2 { + t.Fatal("cached failure") + } + + entries, _ := os.ReadDir(client.CacheDir) + if len(entries) != 0 { + t.Fatalf("cached failure: %v", entries) + } + }) + } +} + +func TestDescriptionCacheUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = fmt.Fprint(w, `{"name":"demo"}`) })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + + client := Client{BaseURL: server.URL, CacheDir: path} + if d, err := client.Description(context.Background(), "demo", "v1"); err != nil || d.Name != "demo" { + t.Fatalf("cache error blocked network: %v", err) + } +} + +func TestDescriptionCacheKeyIsolation(t *testing.T) { + a := Client{} + b := Client{BaseURL: DefaultBaseURL} + c := Client{BaseURL: "https://example.test"} + + url := DefaultBaseURL + "/apis/meet/v2/rest" + if a.descriptionCacheKey(url) == b.descriptionCacheKey(url) { + t.Fatal("fallback policy not isolated") + } + + if b.descriptionCacheKey(url) == c.descriptionCacheKey(c.BaseURL+"/apis/meet/v2/rest") { + t.Fatal("endpoints not isolated") + } + + if b.descriptionCacheKey(url) == b.descriptionCacheKey(DefaultBaseURL+"/apis/meet/v1/rest") { + t.Fatal("versions not isolated") + } +} + +func TestDescriptionCacheBoundsAndConcurrentWrites(t *testing.T) { + client := Client{CacheDir: t.TempDir()} + + unrelated := filepath.Join(client.CacheDir, "keep.txt") + if err := os.WriteFile(unrelated, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + + staleTemp := filepath.Join(client.CacheDir, descriptionCacheTempPrefix+"abandoned") + if err := os.WriteFile(staleTemp, []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for i := range descriptionCacheMaxEntries + 8 { + wg.Go(func() { client.cacheDescription(client.descriptionCacheKey(fmt.Sprint(i)), []byte(`{"name":"demo"}`)) }) + } + + wg.Wait() + + entries, readDirErr := os.ReadDir(client.CacheDir) + if readDirErr != nil { + t.Fatal(readDirErr) + } + count := 0 + + for _, e := range entries { + if isDescriptionCacheName(e.Name()) { + count++ + + b, err := os.ReadFile(filepath.Join(client.CacheDir, e.Name())) + if err != nil || !json.Valid(b) { + t.Fatalf("partial record: %v", err) + } + } + } + + if count > descriptionCacheMaxEntries { + t.Fatalf("cached %d entries", count) + } + + if _, err := os.Stat(unrelated); err != nil { + t.Fatal("removed unrelated file") + } + + if _, err := os.Stat(staleTemp); !os.IsNotExist(err) { + t.Fatal("abandoned temp not removed") + } + // Sparse files prove byte eviction without allocating a large test buffer. + for i := range 5 { + path := filepath.Join(client.CacheDir, client.descriptionCacheKey(fmt.Sprint("large", i))) + + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + err = f.Truncate(descriptionCacheMaxEntryBytes) + _ = f.Close() + + if err != nil { + t.Fatal(err) + } + } + + client.cacheDescription(client.descriptionCacheKey("last"), []byte(`{"name":"last"}`)) + + entries, readDirErr = os.ReadDir(client.CacheDir) + if readDirErr != nil { + t.Fatal(readDirErr) + } + var total int64 + + for _, e := range entries { + if isDescriptionCacheName(e.Name()) { + info, err := e.Info() + if err != nil { + t.Fatal(err) + } + total += info.Size() + } + } + + if total > descriptionCacheMaxBytes { + t.Fatalf("cached %d bytes", total) + } +} + +func TestDescriptionCacheFallbackAndDirectoryRemainIndependent(t *testing.T) { + var calls atomic.Int32 + + client := Client{CacheDir: t.TempDir(), HTTP: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls.Add(1) + code := 200 + body := `{"name":"meet","version":"v2"}` + + if strings.Contains(r.URL.Path, "/apis/meet/") { + code = 404 + body = `{}` + } + + return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })}} + for range 2 { + if _, err := client.Description(context.Background(), "meet", "v2"); err != nil { + t.Fatal(err) + } + } + + if calls.Load() != 2 { + t.Fatalf("fallback cache requests=%d", calls.Load()) + } + + for range 2 { + if _, err := client.List(context.Background(), true); err != nil { + t.Fatal(err) + } + } + + if calls.Load() != 4 { + t.Fatal("directory listing was cached") + } + explicit := client + + explicit.BaseURL = DefaultBaseURL + if _, err := explicit.Description(context.Background(), "meet", "v2"); err == nil { + t.Fatal("explicit endpoint reused fallback entry") + } +} diff --git a/internal/discoveryapi/discovery.go b/internal/discoveryapi/discovery.go index e26cc3e24..8fa258cdd 100644 --- a/internal/discoveryapi/discovery.go +++ b/internal/discoveryapi/discovery.go @@ -28,8 +28,9 @@ var ( ) type Client struct { - BaseURL string - HTTP *http.Client + BaseURL string + HTTP *http.Client + CacheDir string } type responseError struct { @@ -85,6 +86,17 @@ func (c Client) Description(ctx context.Context, api, version string) (*discover u := c.baseURL() + "/apis/" + url.PathEscape(api) + "/" + url.PathEscape(version) + "/rest" + cacheKey := c.descriptionCacheKey(u) + if c.CacheDir != "" { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("load Discovery document: %w", err) + } + + if description := c.cachedDescription(cacheKey); description != nil { + return description, nil + } + } + raw, err := c.get(ctx, u) var responseErr *responseError @@ -103,6 +115,10 @@ func (c Client) Description(ctx context.Context, api, version string) (*discover return nil, fmt.Errorf("decode Discovery document: %w", err) } + if ctx.Err() == nil { + c.cacheDescription(cacheKey, raw) + } + return &description, nil } diff --git a/internal/docsmarkdown/headings.go b/internal/docsmarkdown/headings.go new file mode 100644 index 000000000..273930c84 --- /dev/null +++ b/internal/docsmarkdown/headings.go @@ -0,0 +1,368 @@ +package docsmarkdown + +import ( + "regexp" + "strings" +) + +func parseHeading(line string) (int, string) { + prefix, content, ok := parseMarkdownATXHeadingLine(line) + if !ok { + return 0, "" + } + hashes := strings.TrimSpace(prefix) + + return len(hashes), content +} + +var markdownHeadingAnchorRegex = regexp.MustCompile(`\s+\{#([^}\s]+)\}\s*$`) + +// ExplicitHeadingAnchor identifies one source heading and its explicit anchor. +type ExplicitHeadingAnchor struct { + Anchor string + Text string + Occurrence int +} + +type markdownSourceHeading struct { + Text string + Anchor string +} + +func stripMarkdownHeadingAnchor(content string) (string, string) { + match := markdownHeadingAnchorRegex.FindStringSubmatchIndex(content) + if match == nil { + return content, "" + } + anchor := content[match[2]:match[3]] + + return strings.TrimSpace(content[:match[0]]), anchor +} + +// StripHeadingAnchors removes explicit heading anchors outside code fences. +func StripHeadingAnchors(markdown string) string { + lines := strings.SplitAfter(markdown, "\n") + inCodeBlock := false + var codeFenceChar byte + var codeFenceLen int + pendingSetextLine := -1 + + for i, line := range lines { + body, lineEnding := splitMarkdownLineEnding(line) + if fenceChar, fenceLen, ok := markdownCodeFence(body); ok { + pendingSetextLine = -1 + + if inCodeBlock { + if fenceChar == codeFenceChar && fenceLen >= codeFenceLen { + inCodeBlock = false + codeFenceChar = 0 + codeFenceLen = 0 + } + } else { + inCodeBlock = true + codeFenceChar = fenceChar + codeFenceLen = fenceLen + } + + continue + } + + if inCodeBlock { + continue + } + + if prefix, content, ok := parseMarkdownATXHeadingLine(body); ok { + pendingSetextLine = -1 + + stripped, anchor := stripMarkdownHeadingAnchor(content) + if anchor == "" { + continue + } + lines[i] = prefix + stripped + lineEnding + + continue + } + + if isMarkdownSetextUnderline(body) { + if pendingSetextLine >= 0 { + prevBody, prevLineEnding := splitMarkdownLineEnding(lines[pendingSetextLine]) + + stripped, anchor := stripMarkdownHeadingAnchor(prevBody) + if anchor != "" { + lines[pendingSetextLine] = stripped + prevLineEnding + } + } + pendingSetextLine = -1 + + continue + } + + if !isMarkdownSetextHeadingCandidate(body) { + pendingSetextLine = -1 + continue + } + pendingSetextLine = i + } + + return strings.Join(lines, "") +} + +// ExplicitHeadingAnchors returns explicit anchors from parsed Markdown headings. +func ExplicitHeadingAnchors(markdown string) []ExplicitHeadingAnchor { + elements := ParseMarkdown(markdown) + anchors := make([]ExplicitHeadingAnchor, 0) + seen := map[string]int{} + + for _, el := range elements { + if !IsHeadingElement(el.Type) { + continue + } + text := markdownHeadingSourceText(el.Content) + seen[text]++ + + anchor := strings.TrimSpace(el.Anchor) + if anchor == "" { + continue + } + anchors = append(anchors, ExplicitHeadingAnchor{ + Anchor: anchor, + Text: text, + Occurrence: seen[text], + }) + } + + return anchors +} + +// ImportExplicitHeadingAnchors returns anchors in Drive import heading order. +func ImportExplicitHeadingAnchors(markdown string) []ExplicitHeadingAnchor { + headings := markdownImportHeadings(markdown) + anchors := make([]ExplicitHeadingAnchor, 0) + + seen := map[string]int{} + for _, heading := range headings { + seen[heading.Text]++ + if heading.Anchor == "" { + continue + } + anchors = append(anchors, ExplicitHeadingAnchor{ + Anchor: heading.Anchor, + Text: heading.Text, + Occurrence: seen[heading.Text], + }) + } + + return anchors +} + +func markdownImportHeadings(markdown string) []markdownSourceHeading { + var headings []markdownSourceHeading + lines := strings.Split(markdown, "\n") + inCodeBlock := false + var codeFenceChar byte + var codeFenceLen int + pendingSetextLine := "" + pendingSetext := false + + for _, line := range lines { + body := strings.TrimSuffix(line, "\r") + if fenceChar, fenceLen, ok := markdownCodeFence(body); ok { + pendingSetext = false + + if inCodeBlock { + if fenceChar == codeFenceChar && fenceLen >= codeFenceLen { + inCodeBlock = false + codeFenceChar = 0 + codeFenceLen = 0 + } + } else { + inCodeBlock = true + codeFenceChar = fenceChar + codeFenceLen = fenceLen + } + + continue + } + + if inCodeBlock { + continue + } + + if _, content, ok := parseMarkdownATXHeadingLine(body); ok { + headings = append(headings, markdownSourceHeadingFromContent(content)) + pendingSetext = false + + continue + } + + if isMarkdownSetextUnderline(body) { + if pendingSetext { + headings = append(headings, markdownSourceHeadingFromContent(pendingSetextLine)) + } + pendingSetext = false + + continue + } + + if !isMarkdownSetextHeadingCandidate(body) { + pendingSetext = false + continue + } + pendingSetextLine = body + pendingSetext = true + } + + return headings +} + +func markdownSourceHeadingFromContent(content string) markdownSourceHeading { + stripped, anchor := stripMarkdownHeadingAnchor(content) + + return markdownSourceHeading{ + Text: markdownHeadingSourceText(stripped), + Anchor: strings.TrimSpace(anchor), + } +} + +func markdownHeadingSourceText(content string) string { + stripped, _ := stripMarkdownHeadingAnchor(content) + _, text := ParseInlineFormatting(stripped) + + return HeadingNormalizedText(text) +} + +// HeadingNormalizedText collapses heading whitespace for stable matching. +func HeadingNormalizedText(text string) string { + return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") +} + +func splitMarkdownLineEnding(line string) (string, string) { + body := line + lineEnding := "" + + if strings.HasSuffix(body, "\n") { + body = strings.TrimSuffix(body, "\n") + lineEnding = "\n" + } + + if strings.HasSuffix(body, "\r") { + body = strings.TrimSuffix(body, "\r") + lineEnding = "\r" + lineEnding + } + + return body, lineEnding +} + +func parseMarkdownATXHeadingLine(line string) (string, string, bool) { + spaces := 0 + for spaces < len(line) && line[spaces] == ' ' { + spaces++ + } + + if spaces > 3 || spaces >= len(line) || line[spaces] != '#' { + return "", "", false + } + + hashEnd := spaces + for hashEnd < len(line) && line[hashEnd] == '#' { + hashEnd++ + } + + if hashEnd-spaces > 6 || hashEnd >= len(line) { + return "", "", false + } + + if line[hashEnd] != ' ' && line[hashEnd] != '\t' { + return "", "", false + } + + contentStart := hashEnd + for contentStart < len(line) && (line[contentStart] == ' ' || line[contentStart] == '\t') { + contentStart++ + } + + if contentStart >= len(line) { + return "", "", false + } + + return line[:contentStart], line[contentStart:], true +} + +func isMarkdownSetextUnderline(line string) bool { + if !markdownLineAllowsSetextIndent(line) { + return false + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + + ch := trimmed[0] + if ch != '=' && ch != '-' { + return false + } + + for i := 0; i < len(trimmed); i++ { + if trimmed[i] != ch { + return false + } + } + + return true +} + +func isMarkdownSetextHeadingCandidate(line string) bool { + if !markdownLineAllowsSetextIndent(line) { + return false + } + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + + if strings.HasPrefix(trimmed, ">") || strings.HasPrefix(trimmed, "|") { + return false + } + + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") { + return false + } + + if markdownNumberedListRE.MatchString(trimmed) { + return false + } + + return true +} + +func markdownLineAllowsSetextIndent(line string) bool { + spaces := 0 + for spaces < len(line) && line[spaces] == ' ' { + spaces++ + } + + if spaces > 3 { + return false + } + + return spaces >= len(line) || line[spaces] != '\t' +} + +// IsHeadingElement reports whether an element is a heading. +func IsHeadingElement(t MarkdownElementType) bool { + return t >= MDHeading1 && t <= MDHeading6 +} + +// StripElementHeadingAnchors removes explicit anchors from parsed heading content. +func StripElementHeadingAnchors(elements []MarkdownElement) { + for i := range elements { + if IsHeadingElement(elements[i].Type) { + if stripped, anchor := stripMarkdownHeadingAnchor(elements[i].Content); anchor != "" { + elements[i].Content = stripped + elements[i].Anchor = anchor + } + } + } +} diff --git a/internal/docsmarkdown/inline.go b/internal/docsmarkdown/inline.go new file mode 100644 index 000000000..a5f99eb27 --- /dev/null +++ b/internal/docsmarkdown/inline.go @@ -0,0 +1,393 @@ +package docsmarkdown + +import ( + "strings" + "unicode" + "unicode/utf16" + "unicode/utf8" +) + +// TextStyle represents text formatting +type TextStyle struct { + Bold bool + Italic bool + Strikethrough bool + Code bool + Link string + Start int64 + End int64 +} + +// utf16Len returns the number of UTF-16 code units in a string +func utf16Len(s string) int64 { + return int64(len(utf16.Encode([]rune(s)))) +} + +// ParseInlineFormatting parses inline markdown formatting within text +// Returns styles with indices relative to the stripped plain text (UTF-16 code units) +func ParseInlineFormatting(text string) ([]TextStyle, string) { + stripped, styles := parseInlineSegment(text) + return styles, stripped +} + +func parseInlineSegment(text string) (string, []TextStyle) { + var stripped strings.Builder + var styles []TextStyle + + for i := 0; i < len(text); { + if text[i] == '`' { + if content, end, ok := parseInlineCodeSpan(text, i); ok { + start := utf16Len(stripped.String()) + stripped.WriteString(content) + styles = append(styles, TextStyle{Start: start, End: start + utf16Len(content), Code: true}) + i = end + + continue + } + } + + if text[i] == '[' { + if label, url, end, ok := parseInlineLink(text[i:]); ok { + labelText, labelStyles := parseInlineSegment(label) + start := utf16Len(stripped.String()) + stripped.WriteString(labelText) + styles = appendShiftedStyles(styles, labelStyles, start) + styles = append(styles, TextStyle{Start: start, End: start + utf16Len(labelText), Link: url}) + i += end + + continue + } + } + + if marker, bold, italic, strikethrough, ok := inlineMarkerAt(text, i); ok { + searchFrom := i + len(marker) + if end := findClosingInlineMarker(text, searchFrom, marker); end >= 0 && end > searchFrom { + content, nestedStyles := parseInlineSegment(text[searchFrom:end]) + start := utf16Len(stripped.String()) + stripped.WriteString(content) + styles = append(styles, TextStyle{ + Start: start, + End: start + utf16Len(content), + Bold: bold, + Italic: italic, + Strikethrough: strikethrough, + }) + styles = appendShiftedStyles(styles, nestedStyles, start) + i = end + len(marker) + + continue + } + } + + char, size := nextRune(text[i:]) + stripped.WriteString(char) + i += size + } + + return stripped.String(), styles +} + +func parseInlineCodeSpan(text string, i int) (content string, end int, ok bool) { + runEnd := i + for runEnd < len(text) && text[runEnd] == '`' { + runEnd++ + } + marker := text[i:runEnd] + + searchFrom := runEnd + for { + rel := strings.Index(text[searchFrom:], marker) + if rel < 0 { + return "", 0, false + } + closeStart := searchFrom + rel + + closeEnd := closeStart + len(marker) + if closeEnd < len(text) && text[closeEnd] == '`' { + searchFrom = closeEnd + continue + } + + content = text[runEnd:closeStart] + if content == "" { + return "", 0, false + } + + return content, closeEnd, true + } +} + +func parseInlineLink(text string) (label string, url string, end int, ok bool) { + labelEndRel := strings.IndexByte(text[1:], ']') + if labelEndRel < 0 { + return "", "", 0, false + } + + labelEnd := 1 + labelEndRel + if labelEnd+1 >= len(text) || text[labelEnd+1] != '(' { + return "", "", 0, false + } + urlStart := labelEnd + len("](") + + urlEndRel := strings.IndexByte(text[urlStart:], ')') + if urlEndRel < 0 { + return "", "", 0, false + } + + return text[1:labelEnd], text[urlStart : urlStart+urlEndRel], urlStart + urlEndRel + 1, true +} + +func appendShiftedStyles(styles []TextStyle, nested []TextStyle, offset int64) []TextStyle { + for _, style := range nested { + style.Start += offset + style.End += offset + styles = append(styles, style) + } + + return styles +} + +func inlineMarkerAt(text string, i int) (marker string, bold bool, italic bool, strikethrough bool, ok bool) { + for _, candidate := range []string{"***", "___", "**", "__", "~~", "*", "_"} { + if !strings.HasPrefix(text[i:], candidate) { + continue + } + + if candidate[0] == '_' && !isUnderscoreOpeningDelimiter(text, i, len(candidate)) { + return "", false, false, false, false + } + + if candidate == "~~" { + if i > 0 && text[i-1] == '~' { + return "", false, false, false, false + } + + if tildeRunLenAt(text, i) != len(candidate) { + return "", false, false, false, false + } + + return candidate, false, false, true, true + } + + switch len(candidate) { + case 3: + return candidate, true, true, false, true + case 2: + return candidate, true, false, false, true + default: + return candidate, false, true, false, true + } + } + + return "", false, false, false, false +} + +func tildeRunLenAt(text string, i int) int { + runEnd := i + for runEnd < len(text) && text[runEnd] == '~' { + runEnd++ + } + + return runEnd - i +} + +func findClosingInlineMarker(text string, searchFrom int, marker string) int { + for i := searchFrom; i < len(text); { + if text[i] == '`' { + if _, end, ok := parseInlineCodeSpan(text, i); ok { + i = end + continue + } + } + + if text[i] == marker[0] { + closeIdx, next, ok := closingInlineMarkerInRun(text, searchFrom, i, marker) + if ok && (marker[0] != '_' || isUnderscoreClosingDelimiter(text, closeIdx, len(marker))) { + return closeIdx + } + i = next + + continue + } + _, size := utf8.DecodeRuneInString(text[i:]) + i += size + } + + return -1 +} + +func closingInlineMarkerInRun(text string, searchFrom int, i int, marker string) (closeIdx int, next int, ok bool) { + runEnd := i + for runEnd < len(text) && text[runEnd] == marker[0] { + runEnd++ + } + runLen := runEnd - i + + markerLen := len(marker) + if runLen < markerLen { + return 0, runEnd, false + } + + if marker == "~~" { + return i, runEnd, runLen == markerLen + } + + if markerLen == 1 { + if runLen == 1 { + return i, runEnd, true + } + + if isClosingInlineDelimiterRun(text, i) { + if runLen%2 == 0 { + if hasLaterSingleClosingMarker(text, runEnd, marker[0]) { + return 0, runEnd, false + } + + return i, runEnd, true + } + + return runEnd - 1, runEnd, true + } + + return 0, runEnd, false + } + + if runLen == markerLen || runLen%(markerLen*2) == 0 { + return i, runEnd, true + } + + if markerLen == 2 && runLen == 3 && isClosingInlineDelimiterRun(text, i) { + if hasUnclosedSingleMarker(text[searchFrom:i], marker[0]) { + return runEnd - markerLen, runEnd, true + } + + return i, runEnd, true + } + + return 0, runEnd, false +} + +func isClosingInlineDelimiterRun(text string, i int) bool { + before, hasBefore := runeBefore(text, i) + return hasBefore && !unicode.IsSpace(before) +} + +func hasUnclosedSingleMarker(text string, marker byte) bool { + open := false + + for i := 0; i < len(text); { + if text[i] == '`' { + if _, end, ok := parseInlineCodeSpan(text, i); ok { + i = end + continue + } + } + + if text[i] != marker { + _, size := utf8.DecodeRuneInString(text[i:]) + i += size + + continue + } + + runEnd := i + for runEnd < len(text) && text[runEnd] == marker { + runEnd++ + } + + if runEnd-i == 1 { + open = !open + } + i = runEnd + } + + return open +} + +func hasLaterSingleClosingMarker(text string, from int, marker byte) bool { + for i := from; i < len(text); { + if text[i] == '`' { + if _, end, ok := parseInlineCodeSpan(text, i); ok { + i = end + continue + } + } + + if text[i] != marker { + _, size := utf8.DecodeRuneInString(text[i:]) + i += size + + continue + } + + runEnd := i + for runEnd < len(text) && text[runEnd] == marker { + runEnd++ + } + + runLen := runEnd - i + if isClosingInlineDelimiterRun(text, i) && runLen%2 == 1 { + return true + } + i = runEnd + } + + return false +} + +func isUnderscoreOpeningDelimiter(text string, i int, size int) bool { + before, hasBefore := runeBefore(text, i) + + after, hasAfter := runeAfter(text, i+size) + if !hasAfter || unicode.IsSpace(after) { + return false + } + + return !(hasBefore && isMarkdownWordRune(before) && isMarkdownWordRune(after)) +} + +func isUnderscoreClosingDelimiter(text string, i int, size int) bool { + before, hasBefore := runeBefore(text, i) + after, hasAfter := runeAfter(text, i+size) + + if !hasBefore || unicode.IsSpace(before) { + return false + } + + return !(hasAfter && isMarkdownWordRune(before) && isMarkdownWordRune(after)) +} + +func runeBefore(text string, i int) (rune, bool) { + if i <= 0 { + return 0, false + } + r, _ := utf8.DecodeLastRuneInString(text[:i]) + + return r, r != utf8.RuneError +} + +func runeAfter(text string, i int) (rune, bool) { + if i >= len(text) { + return 0, false + } + r, _ := utf8.DecodeRuneInString(text[i:]) + + return r, r != utf8.RuneError +} + +func isMarkdownWordRune(r rune) bool { + return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) +} + +// nextRune returns the first rune and its byte size from a string. +// For a string consisting of a single multi-byte rune (e.g. Thai or other +// non-ASCII text), the previous range-based implementation returned size 0, +// which caused callers like ParseInlineFormatting to spin in an infinite loop. +func nextRune(s string) (string, int) { + if s == "" { + return "", 0 + } + _, size := utf8.DecodeRuneInString(s) + + return s[:size], size +} diff --git a/internal/docsmarkdown/markdown.go b/internal/docsmarkdown/markdown.go index ffac39338..7944f6347 100644 --- a/internal/docsmarkdown/markdown.go +++ b/internal/docsmarkdown/markdown.go @@ -2,11 +2,8 @@ package docsmarkdown import ( "regexp" - "sort" + "slices" "strings" - "unicode" - "unicode/utf16" - "unicode/utf8" ) // MarkdownElementType represents the type of markdown element @@ -20,13 +17,7 @@ const ( MDHeading4 MDHeading5 MDHeading6 - MDBold - MDItalic - MDBoldItalic - MDCode MDCodeBlock - MDLink - MDImage MDListItem MDNumberedList MDBlockquote @@ -40,29 +31,11 @@ const ( type MarkdownElement struct { Type MarkdownElementType Content string - Anchor string // for headings: explicit Pandoc-style {#id} - Children []MarkdownElement - URL string // for links + Anchor string // for headings: explicit Pandoc-style {#id} Level int // for headings and lists TableCells [][]string // for tables: rows of cells } -// TextStyle represents text formatting -type TextStyle struct { - Bold bool - Italic bool - Strikethrough bool - Code bool - Link string - Start int64 - End int64 -} - -// utf16Len returns the number of UTF-16 code units in a string -func utf16Len(s string) int64 { - return int64(len(utf16.Encode([]rune(s)))) -} - // ParseMarkdown parses markdown text into structured elements func ParseMarkdown(text string) []MarkdownElement { var elements []MarkdownElement @@ -295,7 +268,7 @@ func markdownListLevel(indent int, indents *[]int) int { } } *indents = append(*indents, indent) - sort.Ints(*indents) + slices.Sort(*indents) for i, seen := range *indents { if seen == indent { @@ -306,1041 +279,6 @@ func markdownListLevel(indent int, indents *[]int) int { return len(*indents) } -// IsTableSeparator checks if a line is a markdown table separator (|---|---|). -func IsTableSeparator(line string) bool { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "|") || !strings.HasSuffix(trimmed, "|") { - return false - } - // Remove outer pipes - inner := strings.Trim(trimmed, "|") - // Split by | and check each segment - segments := strings.Split(inner, "|") - // A genuine separator must have at least one segment that actually contains - // dashes. Without this guard a row of empty pipe cells (e.g. an empty - // markdown table header `| | |`) would be misclassified as a - // separator because every segment hits the `continue` and never trips the - // dash check — see #609. - sawDashSegment := false - - for _, seg := range segments { - seg = strings.TrimSpace(seg) - if seg == "" { - continue - } - // Each segment should be only dashes (with optional leading/trailing colon for alignment) - for i, c := range seg { - if c != '-' && c != ' ' && c != ':' { - return false - } - // Colon only allowed at start or end for alignment - if c == ':' && i != 0 && i != len(seg)-1 { - return false - } - } - // Must have at least one dash - if strings.Count(seg, "-") == 0 { - return false - } - sawDashSegment = true - } - - return sawDashSegment -} - -// parseMarkdownTable parses a markdown table into rows of cells -func parseMarkdownTable(lines []string) [][]string { - var rows [][]string - - for lineIndex, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - break - } - - if !strings.HasPrefix(line, "|") { - break - } - // The parser only calls this after recognizing the second line as the - // table delimiter. Separator-shaped rows later in the table are data. - if lineIndex == 1 && IsTableSeparator(line) { - continue - } - - // Parse row: | cell1 | cell2 | cell3 | - cells := ParseTableRow(line) - if len(cells) > 0 { - rows = append(rows, cells) - } - } - - if len(rows) > 1 && isEmptyMarkdownTableRow(rows[0]) { - rows = rows[1:] - } - - return rows -} - -func countMarkdownTableLines(lines []string) int { - count := 0 - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || !strings.HasPrefix(line, "|") { - break - } - - count++ - } - - return count -} - -// ParseTableRow parses a single table row into cells. -func ParseTableRow(line string) []string { - // Remove outer pipes - trimmed := strings.Trim(line, "|") - - // Split by | - parts := strings.Split(trimmed, "|") - - cells := make([]string, 0, len(parts)) - for _, part := range parts { - cell := strings.TrimSpace(part) - cell = normalizeMarkdownTableBreaks(cell) - cells = append(cells, cell) - } - - return cells -} - -func normalizeMarkdownTableBreaks(cell string) string { - var out strings.Builder - changed := false - - for i := 0; i < len(cell); { - if cell[i] == '\\' && i+1 < len(cell) { - out.WriteString(cell[i : i+2]) - i += 2 - - continue - } - - if cell[i] == '`' { - if _, end, ok := parseInlineCodeSpan(cell, i); ok { - out.WriteString(cell[i:end]) - i = end - - continue - } - } - - if breakLen := markdownTableBreakPrefixLen(cell[i:]); breakLen > 0 { - out.WriteByte('\n') - i += breakLen - changed = true - - continue - } - - out.WriteByte(cell[i]) - i++ - } - - if !changed { - return cell - } - - return out.String() -} - -func markdownTableBreakPrefixLen(text string) int { - if len(text) < len("<br>") || text[0] != '<' || - (text[1] != 'b' && text[1] != 'B') || - (text[2] != 'r' && text[2] != 'R') { - return 0 - } - - i := 3 - for i < len(text) && (text[i] == ' ' || text[i] == '\t') { - i++ - } - - if i < len(text) && text[i] == '/' { - i++ - for i < len(text) && (text[i] == ' ' || text[i] == '\t') { - i++ - } - } - - if i >= len(text) || text[i] != '>' { - return 0 - } - - return i + 1 -} - -func isEmptyMarkdownTableRow(cells []string) bool { - if len(cells) == 0 { - return false - } - - for _, cell := range cells { - if strings.TrimSpace(cell) != "" { - return false - } - } - - return true -} - -// NormalizeTablesForDriveImport ensures Drive conversion receives non-empty table headers. -func NormalizeTablesForDriveImport(markdown string) string { - lines := strings.Split(markdown, "\n") - out := make([]string, 0, len(lines)) - inFence := false - fenceMarker := "" - - for i := 0; i < len(lines); i++ { - line := lines[i] - if marker := docsMarkdownFenceMarker(line); marker != "" { - if !inFence { - inFence = true - fenceMarker = marker - } else if marker == fenceMarker { - inFence = false - fenceMarker = "" - } - - out = append(out, line) - - continue - } - - if inFence || !isMarkdownTableCandidateLine(line) || i+2 >= len(lines) || !IsTableSeparator(lines[i+1]) || isIndentedMarkdownCodeLine(lines[i+1]) { - out = append(out, line) - continue - } - - header := ParseTableRow(strings.TrimSpace(line)) - if !isEmptyMarkdownTableRow(header) || !isMarkdownTableCandidateLine(lines[i+2]) { - out = append(out, line) - continue - } - out = append(out, lines[i+2], lines[i+1]) - - i += 2 - for i+1 < len(lines) { - next := lines[i+1] - if strings.TrimSpace(next) == "" || !strings.HasPrefix(strings.TrimSpace(next), "|") { - break - } - out = append(out, next) - i++ - } - } - - return strings.Join(out, "\n") -} - -func docsMarkdownFenceMarker(line string) string { - trimmed := strings.TrimSpace(line) - switch { - case strings.HasPrefix(trimmed, "```"): - return "```" - case strings.HasPrefix(trimmed, "~~~"): - return "~~~" - default: - return "" - } -} - -func isMarkdownTableCandidateLine(line string) bool { - return !isIndentedMarkdownCodeLine(line) && strings.HasPrefix(strings.TrimSpace(line), "|") -} - -func isIndentedMarkdownCodeLine(line string) bool { - return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") -} - -// HasTableCellBreaks reports whether a Markdown table contains HTML line breaks. -func HasTableCellBreaks(markdown string) bool { - lines := strings.Split(markdown, "\n") - inFence := false - fenceMarker := "" - - for i := 0; i < len(lines); i++ { - line := lines[i] - if marker := docsMarkdownFenceMarker(line); marker != "" { - if !inFence { - inFence = true - fenceMarker = marker - } else if marker == fenceMarker { - inFence = false - fenceMarker = "" - } - - continue - } - - if inFence || !isMarkdownTableCandidateLine(line) || i+1 >= len(lines) || !IsTableSeparator(lines[i+1]) { - continue - } - - if normalizeMarkdownTableBreaks(line) != line { - return true - } - - for j := i + 2; j < len(lines); j++ { - row := lines[j] - if strings.TrimSpace(row) == "" || !isMarkdownTableCandidateLine(row) { - break - } - - if normalizeMarkdownTableBreaks(row) != row { - return true - } - } - } - - return false -} - -// ParseInlineFormatting parses inline markdown formatting within text -// Returns styles with indices relative to the stripped plain text (UTF-16 code units) -func ParseInlineFormatting(text string) ([]TextStyle, string) { - stripped, styles := parseInlineSegment(text) - return styles, stripped -} - -func parseInlineSegment(text string) (string, []TextStyle) { - var stripped strings.Builder - var styles []TextStyle - - for i := 0; i < len(text); { - if text[i] == '`' { - if content, end, ok := parseInlineCodeSpan(text, i); ok { - start := utf16Len(stripped.String()) - stripped.WriteString(content) - styles = append(styles, TextStyle{Start: start, End: start + utf16Len(content), Code: true}) - i = end - - continue - } - } - - if text[i] == '[' { - if label, url, end, ok := parseInlineLink(text[i:]); ok { - labelText, labelStyles := parseInlineSegment(label) - start := utf16Len(stripped.String()) - stripped.WriteString(labelText) - styles = appendShiftedStyles(styles, labelStyles, start) - styles = append(styles, TextStyle{Start: start, End: start + utf16Len(labelText), Link: url}) - i += end - - continue - } - } - - if marker, bold, italic, strikethrough, ok := inlineMarkerAt(text, i); ok { - searchFrom := i + len(marker) - if end := findClosingInlineMarker(text, searchFrom, marker); end >= 0 && end > searchFrom { - content, nestedStyles := parseInlineSegment(text[searchFrom:end]) - start := utf16Len(stripped.String()) - stripped.WriteString(content) - styles = append(styles, TextStyle{ - Start: start, - End: start + utf16Len(content), - Bold: bold, - Italic: italic, - Strikethrough: strikethrough, - }) - styles = appendShiftedStyles(styles, nestedStyles, start) - i = end + len(marker) - - continue - } - } - - char, size := nextRune(text[i:]) - stripped.WriteString(char) - i += size - } - - return stripped.String(), styles -} - -func parseInlineCodeSpan(text string, i int) (content string, end int, ok bool) { - runEnd := i - for runEnd < len(text) && text[runEnd] == '`' { - runEnd++ - } - marker := text[i:runEnd] - - searchFrom := runEnd - for { - rel := strings.Index(text[searchFrom:], marker) - if rel < 0 { - return "", 0, false - } - closeStart := searchFrom + rel - - closeEnd := closeStart + len(marker) - if closeEnd < len(text) && text[closeEnd] == '`' { - searchFrom = closeEnd - continue - } - - content = text[runEnd:closeStart] - if content == "" { - return "", 0, false - } - - return content, closeEnd, true - } -} - -func parseInlineLink(text string) (label string, url string, end int, ok bool) { - labelEndRel := strings.IndexByte(text[1:], ']') - if labelEndRel < 0 { - return "", "", 0, false - } - - labelEnd := 1 + labelEndRel - if labelEnd+1 >= len(text) || text[labelEnd+1] != '(' { - return "", "", 0, false - } - urlStart := labelEnd + len("](") - - urlEndRel := strings.IndexByte(text[urlStart:], ')') - if urlEndRel < 0 { - return "", "", 0, false - } - - return text[1:labelEnd], text[urlStart : urlStart+urlEndRel], urlStart + urlEndRel + 1, true -} - -func appendShiftedStyles(styles []TextStyle, nested []TextStyle, offset int64) []TextStyle { - for _, style := range nested { - style.Start += offset - style.End += offset - styles = append(styles, style) - } - - return styles -} - -func inlineMarkerAt(text string, i int) (marker string, bold bool, italic bool, strikethrough bool, ok bool) { - for _, candidate := range []string{"***", "___", "**", "__", "~~", "*", "_"} { - if !strings.HasPrefix(text[i:], candidate) { - continue - } - - if candidate[0] == '_' && !isUnderscoreOpeningDelimiter(text, i, len(candidate)) { - return "", false, false, false, false - } - - if candidate == "~~" { - if i > 0 && text[i-1] == '~' { - return "", false, false, false, false - } - - if tildeRunLenAt(text, i) != len(candidate) { - return "", false, false, false, false - } - - return candidate, false, false, true, true - } - - switch len(candidate) { - case 3: - return candidate, true, true, false, true - case 2: - return candidate, true, false, false, true - default: - return candidate, false, true, false, true - } - } - - return "", false, false, false, false -} - -func tildeRunLenAt(text string, i int) int { - runEnd := i - for runEnd < len(text) && text[runEnd] == '~' { - runEnd++ - } - - return runEnd - i -} - -func findClosingInlineMarker(text string, searchFrom int, marker string) int { - for i := searchFrom; i < len(text); { - if text[i] == '`' { - if _, end, ok := parseInlineCodeSpan(text, i); ok { - i = end - continue - } - } - - if text[i] == marker[0] { - closeIdx, next, ok := closingInlineMarkerInRun(text, searchFrom, i, marker) - if ok && (marker[0] != '_' || isUnderscoreClosingDelimiter(text, closeIdx, len(marker))) { - return closeIdx - } - i = next - - continue - } - _, size := utf8.DecodeRuneInString(text[i:]) - i += size - } - - return -1 -} - -func closingInlineMarkerInRun(text string, searchFrom int, i int, marker string) (closeIdx int, next int, ok bool) { - runEnd := i - for runEnd < len(text) && text[runEnd] == marker[0] { - runEnd++ - } - runLen := runEnd - i - - markerLen := len(marker) - if runLen < markerLen { - return 0, runEnd, false - } - - if marker == "~~" { - return i, runEnd, runLen == markerLen - } - - if markerLen == 1 { - if runLen == 1 { - return i, runEnd, true - } - - if isClosingInlineDelimiterRun(text, i) { - if runLen%2 == 0 { - if hasLaterSingleClosingMarker(text, runEnd, marker[0]) { - return 0, runEnd, false - } - - return i, runEnd, true - } - - return runEnd - 1, runEnd, true - } - - return 0, runEnd, false - } - - if runLen == markerLen || runLen%(markerLen*2) == 0 { - return i, runEnd, true - } - - if markerLen == 2 && runLen == 3 && isClosingInlineDelimiterRun(text, i) { - if hasUnclosedSingleMarker(text[searchFrom:i], marker[0]) { - return runEnd - markerLen, runEnd, true - } - - return i, runEnd, true - } - - return 0, runEnd, false -} - -func isClosingInlineDelimiterRun(text string, i int) bool { - before, hasBefore := runeBefore(text, i) - return hasBefore && !unicode.IsSpace(before) -} - -func hasUnclosedSingleMarker(text string, marker byte) bool { - open := false - - for i := 0; i < len(text); { - if text[i] == '`' { - if _, end, ok := parseInlineCodeSpan(text, i); ok { - i = end - continue - } - } - - if text[i] != marker { - _, size := utf8.DecodeRuneInString(text[i:]) - i += size - - continue - } - - runEnd := i - for runEnd < len(text) && text[runEnd] == marker { - runEnd++ - } - - if runEnd-i == 1 { - open = !open - } - i = runEnd - } - - return open -} - -func hasLaterSingleClosingMarker(text string, from int, marker byte) bool { - for i := from; i < len(text); { - if text[i] == '`' { - if _, end, ok := parseInlineCodeSpan(text, i); ok { - i = end - continue - } - } - - if text[i] != marker { - _, size := utf8.DecodeRuneInString(text[i:]) - i += size - - continue - } - - runEnd := i - for runEnd < len(text) && text[runEnd] == marker { - runEnd++ - } - - runLen := runEnd - i - if isClosingInlineDelimiterRun(text, i) && runLen%2 == 1 { - return true - } - i = runEnd - } - - return false -} - -func isUnderscoreOpeningDelimiter(text string, i int, size int) bool { - before, hasBefore := runeBefore(text, i) - - after, hasAfter := runeAfter(text, i+size) - if !hasAfter || unicode.IsSpace(after) { - return false - } - - return !(hasBefore && isMarkdownWordRune(before) && isMarkdownWordRune(after)) -} - -func isUnderscoreClosingDelimiter(text string, i int, size int) bool { - before, hasBefore := runeBefore(text, i) - after, hasAfter := runeAfter(text, i+size) - - if !hasBefore || unicode.IsSpace(before) { - return false - } - - return !(hasAfter && isMarkdownWordRune(before) && isMarkdownWordRune(after)) -} - -func runeBefore(text string, i int) (rune, bool) { - if i <= 0 { - return 0, false - } - r, _ := utf8.DecodeLastRuneInString(text[:i]) - - return r, r != utf8.RuneError -} - -func runeAfter(text string, i int) (rune, bool) { - if i >= len(text) { - return 0, false - } - r, _ := utf8.DecodeRuneInString(text[i:]) - - return r, r != utf8.RuneError -} - -func isMarkdownWordRune(r rune) bool { - return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) -} - -// nextRune returns the first rune and its byte size from a string. -// For a string consisting of a single multi-byte rune (e.g. Thai or other -// non-ASCII text), the previous range-based implementation returned size 0, -// which caused callers like ParseInlineFormatting to spin in an infinite loop. -func nextRune(s string) (string, int) { - if s == "" { - return "", 0 - } - _, size := utf8.DecodeRuneInString(s) - - return s[:size], size -} - -func parseHeading(line string) (int, string) { - prefix, content, ok := parseMarkdownATXHeadingLine(line) - if !ok { - return 0, "" - } - hashes := strings.TrimSpace(prefix) - - return len(hashes), content -} - -var markdownHeadingAnchorRegex = regexp.MustCompile(`\s+\{#([^}\s]+)\}\s*$`) - -// ExplicitHeadingAnchor identifies one source heading and its explicit anchor. -type ExplicitHeadingAnchor struct { - Anchor string - Text string - Occurrence int -} - -type markdownSourceHeading struct { - Text string - Anchor string -} - -func stripMarkdownHeadingAnchor(content string) (string, string) { - match := markdownHeadingAnchorRegex.FindStringSubmatchIndex(content) - if match == nil { - return content, "" - } - anchor := content[match[2]:match[3]] - - return strings.TrimSpace(content[:match[0]]), anchor -} - -// StripHeadingAnchors removes explicit heading anchors outside code fences. -func StripHeadingAnchors(markdown string) string { - lines := strings.SplitAfter(markdown, "\n") - inCodeBlock := false - var codeFenceChar byte - var codeFenceLen int - pendingSetextLine := -1 - - for i, line := range lines { - body, lineEnding := splitMarkdownLineEnding(line) - if fenceChar, fenceLen, ok := markdownCodeFence(body); ok { - pendingSetextLine = -1 - - if inCodeBlock { - if fenceChar == codeFenceChar && fenceLen >= codeFenceLen { - inCodeBlock = false - codeFenceChar = 0 - codeFenceLen = 0 - } - } else { - inCodeBlock = true - codeFenceChar = fenceChar - codeFenceLen = fenceLen - } - - continue - } - - if inCodeBlock { - continue - } - - if prefix, content, ok := parseMarkdownATXHeadingLine(body); ok { - pendingSetextLine = -1 - - stripped, anchor := stripMarkdownHeadingAnchor(content) - if anchor == "" { - continue - } - lines[i] = prefix + stripped + lineEnding - - continue - } - - if isMarkdownSetextUnderline(body) { - if pendingSetextLine >= 0 { - prevBody, prevLineEnding := splitMarkdownLineEnding(lines[pendingSetextLine]) - - stripped, anchor := stripMarkdownHeadingAnchor(prevBody) - if anchor != "" { - lines[pendingSetextLine] = stripped + prevLineEnding - } - } - pendingSetextLine = -1 - - continue - } - - if !isMarkdownSetextHeadingCandidate(body) { - pendingSetextLine = -1 - continue - } - pendingSetextLine = i - } - - return strings.Join(lines, "") -} - -// ExplicitHeadingAnchors returns explicit anchors from parsed Markdown headings. -func ExplicitHeadingAnchors(markdown string) []ExplicitHeadingAnchor { - elements := ParseMarkdown(markdown) - anchors := make([]ExplicitHeadingAnchor, 0) - seen := map[string]int{} - - for _, el := range elements { - if !IsHeadingElement(el.Type) { - continue - } - text := markdownHeadingSourceText(el.Content) - seen[text]++ - - anchor := strings.TrimSpace(el.Anchor) - if anchor == "" { - continue - } - anchors = append(anchors, ExplicitHeadingAnchor{ - Anchor: anchor, - Text: text, - Occurrence: seen[text], - }) - } - - return anchors -} - -// ImportExplicitHeadingAnchors returns anchors in Drive import heading order. -func ImportExplicitHeadingAnchors(markdown string) []ExplicitHeadingAnchor { - headings := markdownImportHeadings(markdown) - anchors := make([]ExplicitHeadingAnchor, 0) - - seen := map[string]int{} - for _, heading := range headings { - seen[heading.Text]++ - if heading.Anchor == "" { - continue - } - anchors = append(anchors, ExplicitHeadingAnchor{ - Anchor: heading.Anchor, - Text: heading.Text, - Occurrence: seen[heading.Text], - }) - } - - return anchors -} - -func markdownImportHeadings(markdown string) []markdownSourceHeading { - var headings []markdownSourceHeading - lines := strings.Split(markdown, "\n") - inCodeBlock := false - var codeFenceChar byte - var codeFenceLen int - pendingSetextLine := "" - pendingSetext := false - - for _, line := range lines { - body := strings.TrimSuffix(line, "\r") - if fenceChar, fenceLen, ok := markdownCodeFence(body); ok { - pendingSetext = false - - if inCodeBlock { - if fenceChar == codeFenceChar && fenceLen >= codeFenceLen { - inCodeBlock = false - codeFenceChar = 0 - codeFenceLen = 0 - } - } else { - inCodeBlock = true - codeFenceChar = fenceChar - codeFenceLen = fenceLen - } - - continue - } - - if inCodeBlock { - continue - } - - if _, content, ok := parseMarkdownATXHeadingLine(body); ok { - headings = append(headings, markdownSourceHeadingFromContent(content)) - pendingSetext = false - - continue - } - - if isMarkdownSetextUnderline(body) { - if pendingSetext { - headings = append(headings, markdownSourceHeadingFromContent(pendingSetextLine)) - } - pendingSetext = false - - continue - } - - if !isMarkdownSetextHeadingCandidate(body) { - pendingSetext = false - continue - } - pendingSetextLine = body - pendingSetext = true - } - - return headings -} - -func markdownSourceHeadingFromContent(content string) markdownSourceHeading { - stripped, anchor := stripMarkdownHeadingAnchor(content) - - return markdownSourceHeading{ - Text: markdownHeadingSourceText(stripped), - Anchor: strings.TrimSpace(anchor), - } -} - -func markdownHeadingSourceText(content string) string { - stripped, _ := stripMarkdownHeadingAnchor(content) - _, text := ParseInlineFormatting(stripped) - - return HeadingNormalizedText(text) -} - -// HeadingNormalizedText collapses heading whitespace for stable matching. -func HeadingNormalizedText(text string) string { - return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") -} - -func splitMarkdownLineEnding(line string) (string, string) { - body := line - lineEnding := "" - - if strings.HasSuffix(body, "\n") { - body = strings.TrimSuffix(body, "\n") - lineEnding = "\n" - } - - if strings.HasSuffix(body, "\r") { - body = strings.TrimSuffix(body, "\r") - lineEnding = "\r" + lineEnding - } - - return body, lineEnding -} - -func parseMarkdownATXHeadingLine(line string) (string, string, bool) { - spaces := 0 - for spaces < len(line) && line[spaces] == ' ' { - spaces++ - } - - if spaces > 3 || spaces >= len(line) || line[spaces] != '#' { - return "", "", false - } - - hashEnd := spaces - for hashEnd < len(line) && line[hashEnd] == '#' { - hashEnd++ - } - - if hashEnd-spaces > 6 || hashEnd >= len(line) { - return "", "", false - } - - if line[hashEnd] != ' ' && line[hashEnd] != '\t' { - return "", "", false - } - - contentStart := hashEnd - for contentStart < len(line) && (line[contentStart] == ' ' || line[contentStart] == '\t') { - contentStart++ - } - - if contentStart >= len(line) { - return "", "", false - } - - return line[:contentStart], line[contentStart:], true -} - -func isMarkdownSetextUnderline(line string) bool { - if !markdownLineAllowsSetextIndent(line) { - return false - } - - trimmed := strings.TrimSpace(line) - if trimmed == "" { - return false - } - - ch := trimmed[0] - if ch != '=' && ch != '-' { - return false - } - - for i := 0; i < len(trimmed); i++ { - if trimmed[i] != ch { - return false - } - } - - return true -} - -func isMarkdownSetextHeadingCandidate(line string) bool { - if !markdownLineAllowsSetextIndent(line) { - return false - } - - trimmed := strings.TrimSpace(line) - if trimmed == "" { - return false - } - - if strings.HasPrefix(trimmed, ">") || strings.HasPrefix(trimmed, "|") { - return false - } - - if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "+ ") { - return false - } - - if markdownNumberedListRE.MatchString(trimmed) { - return false - } - - return true -} - -func markdownLineAllowsSetextIndent(line string) bool { - spaces := 0 - for spaces < len(line) && line[spaces] == ' ' { - spaces++ - } - - if spaces > 3 { - return false - } - - return spaces >= len(line) || line[spaces] != '\t' -} - -// IsHeadingElement reports whether an element is a heading. -func IsHeadingElement(t MarkdownElementType) bool { - return t >= MDHeading1 && t <= MDHeading6 -} - -// StripElementHeadingAnchors removes explicit anchors from parsed heading content. -func StripElementHeadingAnchors(elements []MarkdownElement) { - for i := range elements { - if IsHeadingElement(elements[i].Type) { - if stripped, anchor := stripMarkdownHeadingAnchor(elements[i].Content); anchor != "" { - elements[i].Content = stripped - elements[i].Anchor = anchor - } - } - - if len(elements[i].Children) > 0 { - StripElementHeadingAnchors(elements[i].Children) - } - } -} - func markdownCodeFence(line string) (byte, int, bool) { i := 0 for i < len(line) && line[i] == ' ' && i < 3 { diff --git a/internal/docsmarkdown/markdown_tables.go b/internal/docsmarkdown/markdown_tables.go new file mode 100644 index 000000000..536da87ea --- /dev/null +++ b/internal/docsmarkdown/markdown_tables.go @@ -0,0 +1,305 @@ +package docsmarkdown + +import ( + "strings" +) + +// IsTableSeparator checks if a line is a markdown table separator (|---|---|). +func IsTableSeparator(line string) bool { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") || !strings.HasSuffix(trimmed, "|") { + return false + } + // Remove outer pipes + inner := strings.Trim(trimmed, "|") + // Split by | and check each segment + segments := strings.Split(inner, "|") + // A genuine separator must have at least one segment that actually contains + // dashes. Without this guard a row of empty pipe cells (e.g. an empty + // markdown table header `| | |`) would be misclassified as a + // separator because every segment hits the `continue` and never trips the + // dash check — see #609. + sawDashSegment := false + + for _, seg := range segments { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + // Each segment should be only dashes (with optional leading/trailing colon for alignment) + for i, c := range seg { + if c != '-' && c != ' ' && c != ':' { + return false + } + // Colon only allowed at start or end for alignment + if c == ':' && i != 0 && i != len(seg)-1 { + return false + } + } + // Must have at least one dash + if strings.Count(seg, "-") == 0 { + return false + } + sawDashSegment = true + } + + return sawDashSegment +} + +// parseMarkdownTable parses a markdown table into rows of cells +func parseMarkdownTable(lines []string) [][]string { + var rows [][]string + + for lineIndex, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + break + } + + if !strings.HasPrefix(line, "|") { + break + } + // The parser only calls this after recognizing the second line as the + // table delimiter. Separator-shaped rows later in the table are data. + if lineIndex == 1 && IsTableSeparator(line) { + continue + } + + // Parse row: | cell1 | cell2 | cell3 | + cells := ParseTableRow(line) + if len(cells) > 0 { + rows = append(rows, cells) + } + } + + if len(rows) > 1 && isEmptyMarkdownTableRow(rows[0]) { + rows = rows[1:] + } + + return rows +} + +func countMarkdownTableLines(lines []string) int { + count := 0 + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.HasPrefix(line, "|") { + break + } + + count++ + } + + return count +} + +// ParseTableRow parses a single table row into cells. +func ParseTableRow(line string) []string { + // Remove outer pipes + trimmed := strings.Trim(line, "|") + + // Split by | + parts := strings.Split(trimmed, "|") + + cells := make([]string, 0, len(parts)) + for _, part := range parts { + cell := strings.TrimSpace(part) + cell = normalizeMarkdownTableBreaks(cell) + cells = append(cells, cell) + } + + return cells +} + +func normalizeMarkdownTableBreaks(cell string) string { + var out strings.Builder + changed := false + + for i := 0; i < len(cell); { + if cell[i] == '\\' && i+1 < len(cell) { + out.WriteString(cell[i : i+2]) + i += 2 + + continue + } + + if cell[i] == '`' { + if _, end, ok := parseInlineCodeSpan(cell, i); ok { + out.WriteString(cell[i:end]) + i = end + + continue + } + } + + if breakLen := markdownTableBreakPrefixLen(cell[i:]); breakLen > 0 { + out.WriteByte('\n') + i += breakLen + changed = true + + continue + } + + out.WriteByte(cell[i]) + i++ + } + + if !changed { + return cell + } + + return out.String() +} + +func markdownTableBreakPrefixLen(text string) int { + if len(text) < len("<br>") || text[0] != '<' || + (text[1] != 'b' && text[1] != 'B') || + (text[2] != 'r' && text[2] != 'R') { + return 0 + } + + i := 3 + for i < len(text) && (text[i] == ' ' || text[i] == '\t') { + i++ + } + + if i < len(text) && text[i] == '/' { + i++ + for i < len(text) && (text[i] == ' ' || text[i] == '\t') { + i++ + } + } + + if i >= len(text) || text[i] != '>' { + return 0 + } + + return i + 1 +} + +func isEmptyMarkdownTableRow(cells []string) bool { + if len(cells) == 0 { + return false + } + + for _, cell := range cells { + if strings.TrimSpace(cell) != "" { + return false + } + } + + return true +} + +// NormalizeTablesForDriveImport ensures Drive conversion receives non-empty table headers. +func NormalizeTablesForDriveImport(markdown string) string { + lines := strings.Split(markdown, "\n") + out := make([]string, 0, len(lines)) + inFence := false + fenceMarker := "" + + for i := 0; i < len(lines); i++ { + line := lines[i] + if marker := docsMarkdownFenceMarker(line); marker != "" { + if !inFence { + inFence = true + fenceMarker = marker + } else if marker == fenceMarker { + inFence = false + fenceMarker = "" + } + + out = append(out, line) + + continue + } + + if inFence || !isMarkdownTableCandidateLine(line) || i+2 >= len(lines) || !IsTableSeparator(lines[i+1]) || isIndentedMarkdownCodeLine(lines[i+1]) { + out = append(out, line) + continue + } + + header := ParseTableRow(strings.TrimSpace(line)) + if !isEmptyMarkdownTableRow(header) || !isMarkdownTableCandidateLine(lines[i+2]) { + out = append(out, line) + continue + } + out = append(out, lines[i+2], lines[i+1]) + + i += 2 + for i+1 < len(lines) { + next := lines[i+1] + if strings.TrimSpace(next) == "" || !strings.HasPrefix(strings.TrimSpace(next), "|") { + break + } + out = append(out, next) + i++ + } + } + + return strings.Join(out, "\n") +} + +func docsMarkdownFenceMarker(line string) string { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "```"): + return "```" + case strings.HasPrefix(trimmed, "~~~"): + return "~~~" + default: + return "" + } +} + +func isMarkdownTableCandidateLine(line string) bool { + return !isIndentedMarkdownCodeLine(line) && strings.HasPrefix(strings.TrimSpace(line), "|") +} + +func isIndentedMarkdownCodeLine(line string) bool { + return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") +} + +// HasTableCellBreaks reports whether a Markdown table contains HTML line breaks. +func HasTableCellBreaks(markdown string) bool { + lines := strings.Split(markdown, "\n") + inFence := false + fenceMarker := "" + + for i := 0; i < len(lines); i++ { + line := lines[i] + if marker := docsMarkdownFenceMarker(line); marker != "" { + if !inFence { + inFence = true + fenceMarker = marker + } else if marker == fenceMarker { + inFence = false + fenceMarker = "" + } + + continue + } + + if inFence || !isMarkdownTableCandidateLine(line) || i+1 >= len(lines) || !IsTableSeparator(lines[i+1]) { + continue + } + + if normalizeMarkdownTableBreaks(line) != line { + return true + } + + for j := i + 2; j < len(lines); j++ { + row := lines[j] + if strings.TrimSpace(row) == "" || !isMarkdownTableCandidateLine(row) { + break + } + + if normalizeMarkdownTableBreaks(row) != row { + return true + } + } + } + + return false +} diff --git a/internal/docsmarkdown/requests.go b/internal/docsmarkdown/requests.go index 67ca4b0e9..7273c05af 100644 --- a/internal/docsmarkdown/requests.go +++ b/internal/docsmarkdown/requests.go @@ -296,10 +296,8 @@ func MarkdownToDocsRequests(elements []MarkdownElement, baseIndex int64, tabID s continue } - rows := len(el.TableCells) - cols := len(el.TableCells[0]) - if rows == 0 || cols == 0 { + if cols == 0 { continue } diff --git a/internal/googleauth/accounts_manager.go b/internal/googleauth/accounts_manager.go index 2695cfeac..093ebae07 100644 --- a/internal/googleauth/accounts_manager.go +++ b/internal/googleauth/accounts_manager.go @@ -2,7 +2,6 @@ package googleauth import ( "context" - "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -658,28 +657,6 @@ func FetchUserIdentity(ctx context.Context, tok *oauth2.Token) (Identity, error) return fetchUserIdentityWithURL(ctx, tok.AccessToken, userinfoURL) } -func fetchUserEmailDefault(ctx context.Context, tok *oauth2.Token) (string, error) { - identity, err := FetchUserIdentity(ctx, tok) - if err != nil { - return "", err - } - - return identity.Email, nil -} - -// fetchUserEmailWithURL retrieves the user's email from the specified userinfo URL. -// This is separated for testability. -// -//nolint:unparam // retained for email-only tests and package callers. -func fetchUserEmailWithURL(ctx context.Context, accessToken string, url string) (string, error) { - identity, err := fetchUserIdentityWithURL(ctx, accessToken, url) - if err != nil { - return "", err - } - - return identity.Email, nil -} - func fetchUserIdentityWithURL(ctx context.Context, accessToken string, url string) (Identity, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -728,15 +705,6 @@ func fetchUserIdentityWithURL(ctx context.Context, accessToken string, url strin return Identity{Subject: subject, Email: email}, nil } -func emailFromIDToken(idToken string) (string, error) { - identity, err := IdentityFromIDToken(idToken) - if err != nil { - return "", err - } - - return identity.Email, nil -} - func IdentityFromIDToken(idToken string) (Identity, error) { parts := strings.Split(idToken, ".") if len(parts) < 2 { @@ -784,10 +752,6 @@ func readHTTPBodySnippet(r io.Reader, limit int64) string { return s } -func generateCSRFToken() (string, error) { - return generateRandomHex(rand.Reader, 32) -} - func generateRandomHex(random io.Reader, size int) (string, error) { b := make([]byte, size) if _, err := io.ReadFull(random, b); err != nil { @@ -817,21 +781,6 @@ func writeJSONError(w http.ResponseWriter, msg string, status int) { _ = json.NewEncoder(w).Encode(map[string]any{"error": msg}) } -// renderSuccessPageWithDetails renders the success template with email and services -func renderSuccessPageWithDetails(w http.ResponseWriter, email string, services []string) { - renderSuccessPageWithDetailsAndCSRF(w, email, services, "") -} - -func renderSuccessPageWithDetailsAndCSRF(w http.ResponseWriter, email string, services []string, csrfToken string) { - tmpl, err := template.New("success").Parse(successTemplate) - if err != nil { - _, _ = w.Write([]byte("Success! You can close this window.")) - return - } - - renderSuccessTemplate(w, tmpl, email, services, csrfToken) -} - func (app *ManagerApplication) renderSuccessPage(w http.ResponseWriter, email string, services []string) { renderSuccessTemplate(w, app.successPage, email, services, app.csrfToken) } diff --git a/internal/googleauth/accounts_server_more_test.go b/internal/googleauth/accounts_server_more_test.go index b4df7f41a..7cdef4288 100644 --- a/internal/googleauth/accounts_server_more_test.go +++ b/internal/googleauth/accounts_server_more_test.go @@ -52,12 +52,12 @@ func TestHandleAccountsPage(t *testing.T) { } } -func TestFetchUserEmailDefault(t *testing.T) { - if _, err := fetchUserEmailDefault(context.TODO(), nil); err == nil { +func TestFetchUserIdentity_DefaultSource(t *testing.T) { + if _, err := FetchUserIdentity(context.TODO(), nil); err == nil { t.Fatalf("expected missing token error") } - if _, err := fetchUserEmailDefault(context.TODO(), &oauth2.Token{}); err == nil { + if _, err := FetchUserIdentity(context.TODO(), &oauth2.Token{}); err == nil { t.Fatalf("expected missing access token error") } @@ -66,13 +66,13 @@ func TestFetchUserEmailDefault(t *testing.T) { tok := &oauth2.Token{AccessToken: "access"} tok = tok.WithExtra(map[string]any{"id_token": idToken}) - email, err := fetchUserEmailDefault(context.TODO(), tok) + identity, err := FetchUserIdentity(context.TODO(), tok) if err != nil { - t.Fatalf("fetchUserEmailDefault: %v", err) + t.Fatalf("FetchUserIdentity: %v", err) } - if email != "a@b.com" { - t.Fatalf("unexpected email: %q", email) + if identity.Email != "a@b.com" { + t.Fatalf("unexpected email: %q", identity.Email) } } @@ -88,9 +88,9 @@ func TestReadHTTPBodySnippet(t *testing.T) { } } -func TestRenderSuccessPageWithDetails_More(t *testing.T) { +func TestManagerRenderSuccessPage_Email(t *testing.T) { rec := httptest.NewRecorder() - renderSuccessPageWithDetails(rec, "a@b.com", []string{"gmail"}) + newTestManagerApplication(t, ManagerOptions{}, ManagerDependencies{}).renderSuccessPage(rec, "a@b.com", []string{"gmail"}) if !strings.Contains(rec.Body.String(), "a@b.com") { t.Fatalf("expected email in success page") @@ -224,7 +224,7 @@ func TestStartManageServerOpenStoreError(t *testing.T) { } } -func TestManageCredentialsReaderUsesContext(t *testing.T) { +func TestReadOAuthClientCredentialsUsesContext(t *testing.T) { origRead := readClientCredentials t.Cleanup(func() { readClientCredentials = origRead }) @@ -238,7 +238,7 @@ func TestManageCredentialsReaderUsesContext(t *testing.T) { return config.ClientCredentials{ClientID: "id", ClientSecret: "secret"}, nil }) - credentials, err := manageCredentialsReader(ctx, nil)("work") + credentials, err := readOAuthClientCredentials(ctx, "work") if err != nil { t.Fatalf("read credentials: %v", err) } @@ -250,12 +250,14 @@ func TestManageCredentialsReaderUsesContext(t *testing.T) { func TestManageCredentialsReaderPreservesOverride(t *testing.T) { called := false - reader := manageCredentialsReader(context.Background(), func(client string) (config.ClientCredentials, error) { - called = true - return config.ClientCredentials{ClientID: client}, nil + app := newTestManagerApplication(t, ManagerOptions{}, ManagerDependencies{ + ReadCredentials: func(client string) (config.ClientCredentials, error) { + called = true + return config.ClientCredentials{ClientID: client}, nil + }, }) - credentials, err := reader("custom") + credentials, err := app.deps.ReadCredentials("custom") if err != nil { t.Fatalf("read credentials: %v", err) } diff --git a/internal/googleauth/accounts_server_test.go b/internal/googleauth/accounts_server_test.go index 3757bab08..acc2815ef 100644 --- a/internal/googleauth/accounts_server_test.go +++ b/internal/googleauth/accounts_server_test.go @@ -688,11 +688,9 @@ func TestManageServer_HandleListAccounts_Error(t *testing.T) { } } -func TestGenerateCSRFToken(t *testing.T) { - token, err := generateCSRFToken() - if err != nil { - t.Fatalf("generateCSRFToken: %v", err) - } +func TestManagerCSRFToken(t *testing.T) { + app := newTestManagerApplication(t, ManagerOptions{}, ManagerDependencies{}) + token := app.csrfToken if len(token) != 64 { t.Fatalf("unexpected token length: %d", len(token)) @@ -703,9 +701,9 @@ func TestGenerateCSRFToken(t *testing.T) { } } -func TestRenderSuccessPageWithDetails(t *testing.T) { +func TestManagerRenderSuccessPage_Details(t *testing.T) { rr := httptest.NewRecorder() - renderSuccessPageWithDetails(rr, "me@example.com", []string{"gmail", "drive"}) + newTestManagerApplication(t, ManagerOptions{}, ManagerDependencies{}).renderSuccessPage(rr, "me@example.com", []string{"gmail", "drive"}) if body := rr.Body.String(); !strings.Contains(body, "me@example.com") { t.Fatalf("expected email in body") @@ -1184,7 +1182,7 @@ func TestManageServer_HandleOAuthCallback_Success_IDTokenEmail(t *testing.T) { } } -func TestFetchUserEmail(t *testing.T) { +func TestFetchUserIdentity_Userinfo(t *testing.T) { t.Run("success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if auth := r.Header.Get("Authorization"); auth != "Bearer test-token" { @@ -1196,13 +1194,13 @@ func TestFetchUserEmail(t *testing.T) { })) defer srv.Close() - email, err := fetchUserEmailWithURL(context.Background(), "test-token", srv.URL) + identity, err := fetchUserIdentityWithURL(context.Background(), "test-token", srv.URL) if err != nil { - t.Fatalf("fetchUserEmail: %v", err) + t.Fatalf("fetchUserIdentity: %v", err) } - if email != "user@test.com" { - t.Fatalf("expected user@test.com, got %q", email) + if identity.Email != "user@test.com" { + t.Fatalf("expected user@test.com, got %q", identity.Email) } }) @@ -1213,7 +1211,7 @@ func TestFetchUserEmail(t *testing.T) { })) defer srv.Close() - _, err := fetchUserEmailWithURL(context.Background(), "test-token", srv.URL) + _, err := fetchUserIdentityWithURL(context.Background(), "test-token", srv.URL) if err == nil { t.Fatal("expected error for empty email") } @@ -1229,7 +1227,7 @@ func TestFetchUserEmail(t *testing.T) { })) defer srv.Close() - _, err := fetchUserEmailWithURL(context.Background(), "test-token", srv.URL) + _, err := fetchUserIdentityWithURL(context.Background(), "test-token", srv.URL) if err == nil { t.Fatal("expected error for 401") } @@ -1246,14 +1244,14 @@ func TestFetchUserEmail(t *testing.T) { })) defer srv.Close() - _, err := fetchUserEmailWithURL(context.Background(), "test-token", srv.URL) + _, err := fetchUserIdentityWithURL(context.Background(), "test-token", srv.URL) if err == nil { t.Fatal("expected error for invalid json") } }) } -func TestEmailFromIDToken(t *testing.T) { +func TestIdentityFromIDToken_Claims(t *testing.T) { t.Run("success", func(t *testing.T) { idToken := strings.Join([]string{ base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)), @@ -1261,15 +1259,6 @@ func TestEmailFromIDToken(t *testing.T) { "", }, ".") - email, err := emailFromIDToken(idToken) - if err != nil { - t.Fatalf("emailFromIDToken: %v", err) - } - - if email != "me@example.com" { - t.Fatalf("expected me@example.com, got %q", email) - } - identity, err := IdentityFromIDToken(idToken) if err != nil { t.Fatalf("IdentityFromIDToken: %v", err) @@ -1281,7 +1270,7 @@ func TestEmailFromIDToken(t *testing.T) { }) t.Run("invalid token", func(t *testing.T) { - _, err := emailFromIDToken("nope") + _, err := IdentityFromIDToken("nope") if err == nil { t.Fatal("expected error") } @@ -1298,7 +1287,7 @@ func TestEmailFromIDToken(t *testing.T) { "", }, ".") - _, err := emailFromIDToken(idToken) + _, err := IdentityFromIDToken(idToken) if err == nil { t.Fatal("expected error") } diff --git a/internal/googleauth/oauth_flow.go b/internal/googleauth/oauth_flow.go index 3333fb726..eace3d981 100644 --- a/internal/googleauth/oauth_flow.go +++ b/internal/googleauth/oauth_flow.go @@ -142,19 +142,6 @@ func readOAuthClientCredentials(ctx context.Context, client string) (config.Clie return credentials, nil } -func manageCredentialsReader( - ctx context.Context, - reader func(client string) (config.ClientCredentials, error), -) func(client string) (config.ClientCredentials, error) { - if reader != nil { - return reader - } - - return func(client string) (config.ClientCredentials, error) { - return readOAuthClientCredentials(ctx, client) - } -} - func newOAuthCallbackServer(handler http.Handler) *http.Server { return &http.Server{ Handler: handler, @@ -369,12 +356,3 @@ func renderCancelledPage(w http.ResponseWriter) { } _ = tmpl.Execute(w, nil) } - -// waitPostSuccess waits for the specified duration or until the context is -// cancelled (e.g., via Ctrl+C). Kept for tests and potential future UX tweaks. -func waitPostSuccess(ctx context.Context, d time.Duration) { - select { - case <-time.After(d): - case <-ctx.Done(): - } -} diff --git a/internal/googleauth/oauth_flow_manual_redirect.go b/internal/googleauth/oauth_flow_manual_redirect.go index e69140ae4..72bf6eda0 100644 --- a/internal/googleauth/oauth_flow_manual_redirect.go +++ b/internal/googleauth/oauth_flow_manual_redirect.go @@ -63,8 +63,3 @@ func parseRedirectURL(rawURL string) (code string, state string, redirectURI str return code, state, redirectURI, nil } - -func extractCodeAndState(rawURL string) (code string, state string, err error) { - code, state, _, err = parseRedirectURL(rawURL) - return code, state, err -} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index f07f9eca9..a2c953308 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -266,7 +266,7 @@ var serviceInfoByService = map[Service]serviceInfo{ scopes: []string{"https://www.googleapis.com/auth/webmasters"}, user: true, apis: []string{"Search Console API"}, - note: "Search Analytics + sitemap management", + note: "Search Analytics + sitemap management + URL Inspection", }, ServiceAdSense: { scopes: []string{"https://www.googleapis.com/auth/adsense.readonly"}, diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 86adb591c..ccb1af4a5 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -55,8 +55,8 @@ func TestParseService_Invalid(t *testing.T) { } } -func TestExtractCodeAndState(t *testing.T) { - code, state, err := extractCodeAndState("http://127.0.0.1:55555/oauth2/callback?code=abc&state=xyz") +func TestParseRedirectURL(t *testing.T) { + code, state, _, err := parseRedirectURL("http://127.0.0.1:55555/oauth2/callback?code=abc&state=xyz") if err != nil { t.Fatalf("err: %v", err) } @@ -66,12 +66,12 @@ func TestExtractCodeAndState(t *testing.T) { } } -func TestExtractCodeAndState_Errors(t *testing.T) { - if _, _, err := extractCodeAndState("not a url"); err == nil { +func TestParseRedirectURL_Errors(t *testing.T) { + if _, _, _, err := parseRedirectURL("not a url"); err == nil { t.Fatalf("expected error") } - if _, _, err := extractCodeAndState("http://127.0.0.1:55555/oauth2/callback?state=xyz"); err == nil { + if _, _, _, err := parseRedirectURL("http://127.0.0.1:55555/oauth2/callback?state=xyz"); err == nil { t.Fatalf("expected error") } } diff --git a/internal/googleauth/wait_post_success_test.go b/internal/googleauth/wait_post_success_test.go deleted file mode 100644 index 69df166f2..000000000 --- a/internal/googleauth/wait_post_success_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package googleauth - -import ( - "context" - "testing" - "time" -) - -func TestWaitPostSuccess_ContextCancellation(t *testing.T) { - t.Parallel() - - // Create a context that will be cancelled after a short delay - ctx, cancel := context.WithCancel(context.Background()) - - // Cancel context after 50ms - go func() { - time.Sleep(50 * time.Millisecond) - cancel() - }() - - start := time.Now() - // Wait for a long duration (1 second) but expect early termination - waitPostSuccess(ctx, 1*time.Second) - elapsed := time.Since(start) - - // Should complete well before the 1-second wait duration - // Allow some tolerance for timing (up to 200ms) - if elapsed >= 500*time.Millisecond { - t.Fatalf("waitPostSuccess did not respect context cancellation: took %v, expected < 500ms", elapsed) - } -} - -func TestWaitPostSuccess_FullDuration(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - start := time.Now() - // Wait for a short duration - waitPostSuccess(ctx, 100*time.Millisecond) - elapsed := time.Since(start) - - // Should complete close to the specified duration - if elapsed < 90*time.Millisecond { - t.Fatalf("waitPostSuccess returned too early: %v, expected ~100ms", elapsed) - } - - if elapsed > 200*time.Millisecond { - t.Fatalf("waitPostSuccess took too long: %v, expected ~100ms", elapsed) - } -} - -func TestWaitPostSuccess_AlreadyCancelledContext(t *testing.T) { - t.Parallel() - - // Create an already-cancelled context - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - start := time.Now() - // Should return immediately since context is already cancelled - waitPostSuccess(ctx, 1*time.Second) - elapsed := time.Since(start) - - // Should complete almost immediately (well under 50ms) - if elapsed >= 50*time.Millisecond { - t.Fatalf("waitPostSuccess did not return immediately for cancelled context: took %v", elapsed) - } -} diff --git a/internal/tracking/worker/package.json b/internal/tracking/worker/package.json index bafa5dee5..aaedfff20 100644 --- a/internal/tracking/worker/package.json +++ b/internal/tracking/worker/package.json @@ -13,14 +13,14 @@ "test": "vitest run" }, "devDependencies": { - "@cloudflare/workers-types": "^5.20260904.1", + "@cloudflare/workers-types": "^5.20260911.1", "@typescript/native": "npm:typescript@^7.0.2", - "oxfmt": "^0.66.0", - "oxlint": "^1.81.0", + "oxfmt": "^0.67.0", + "oxlint": "^1.82.0", "oxlint-tsgolint": "^7.0.2001", "typescript": "npm:@typescript/typescript6@^6.0.2", "vitest": "^5.0.0", - "wrangler": "^4.129.0" + "wrangler": "^4.131.1" }, - "packageManager": "pnpm@11.25.0" + "packageManager": "pnpm@11.26.0" } diff --git a/internal/tracking/worker/pnpm-lock.yaml b/internal/tracking/worker/pnpm-lock.yaml index 9499629f4..f3f6dc1b6 100644 --- a/internal/tracking/worker/pnpm-lock.yaml +++ b/internal/tracking/worker/pnpm-lock.yaml @@ -17,17 +17,17 @@ importers: .: devDependencies: '@cloudflare/workers-types': - specifier: ^5.20260904.1 - version: 5.20260904.1 + specifier: ^5.20260911.1 + version: 5.20260911.1 '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 oxfmt: - specifier: ^0.66.0 - version: 0.66.0 + specifier: ^0.67.0 + version: 0.67.0 oxlint: - specifier: ^1.81.0 - version: 1.81.0(oxlint-tsgolint@7.0.2001) + specifier: ^1.82.0 + version: 1.82.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: specifier: ^7.0.2001 version: 7.0.2001 @@ -38,8 +38,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(vite@8.2.2(esbuild@0.28.2)) wrangler: - specifier: ^4.129.0 - version: 4.129.0(@cloudflare/workers-types@5.20260904.1) + specifier: ^4.131.1 + version: 4.131.1(@cloudflare/workers-types@5.20260911.1) packages: @@ -56,38 +56,38 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260903.1': - resolution: {integrity: sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==} + '@cloudflare/workerd-darwin-64@1.20260911.1': + resolution: {integrity: sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260903.1': - resolution: {integrity: sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==} + '@cloudflare/workerd-darwin-arm64@1.20260911.1': + resolution: {integrity: sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260903.1': - resolution: {integrity: sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==} + '@cloudflare/workerd-linux-64@1.20260911.1': + resolution: {integrity: sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260903.1': - resolution: {integrity: sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==} + '@cloudflare/workerd-linux-arm64@1.20260911.1': + resolution: {integrity: sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260903.1': - resolution: {integrity: sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==} + '@cloudflare/workerd-windows-64@1.20260911.1': + resolution: {integrity: sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@5.20260904.1': - resolution: {integrity: sha512-SbulPvrdb4j5iISDr81Iw+YpNCG4Bb6xRqV3arkgn/Ib6STMaoG63lhPnuWqmZdakOn0qS1OUudBMdxtBFx0Jg==} + '@cloudflare/workers-types@5.20260911.1': + resolution: {integrity: sha512-yiAvknjulcU85B3yB4aKOn9+l+garWP+AbHgsdCFckeRYDdhZ1rPULi64BDf52R3VTaKqxi47kF+o9ZbjsCt5g==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -256,160 +256,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.2': - resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.2': - resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.2': - resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.1': - resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.1': - resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.1': - resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.1': - resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.1': - resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.1': - resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.1': - resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.1': - resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': - resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.1': - resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.2': - resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.2': - resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.2': - resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.2': - resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.2': - resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.2': - resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.2': - resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.2': - resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.2': - resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.2': - resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.2': - resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.2': - resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.2': - resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -430,124 +430,124 @@ packages: '@oxc-project/types@0.147.0': resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} - '@oxfmt/binding-android-arm-eabi@0.66.0': - resolution: {integrity: sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==} + '@oxfmt/binding-android-arm-eabi@0.67.0': + resolution: {integrity: sha512-2olh3ioEmc4gRzQm7jxyB1b/PFBoFvTq8KdgYySeNpysDtA6DEg2Mvya4/I6flhL7G0eOrE8RD7JCNCIMhE16Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.66.0': - resolution: {integrity: sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==} + '@oxfmt/binding-android-arm64@0.67.0': + resolution: {integrity: sha512-ulfw8EHN1MBq/MFFDXw2/M1VAFu5mRUcnuZ8Hqbv9viAnFzO9t1jKSAsDqKYYDGMlytF/uj6Z5z5n/tHupnKhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.66.0': - resolution: {integrity: sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==} + '@oxfmt/binding-darwin-arm64@0.67.0': + resolution: {integrity: sha512-MfONZx/O2o9M5v2jDFol556G9+A+P9xCuJ4DZ+qhE+RnaCdoscy6Eu5nq1dbuNxhwdJyZ6kLI7fnG9mwEeOeGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.66.0': - resolution: {integrity: sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==} + '@oxfmt/binding-darwin-x64@0.67.0': + resolution: {integrity: sha512-CYnIx5LvFVJnyJcCqwH2jxMKjFjqo5678MPjdmNFoSGMhlOvZ/xRZqvhDcolKrXc8fezW3AKh+C4wyoFuWOSSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.66.0': - resolution: {integrity: sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==} + '@oxfmt/binding-freebsd-x64@0.67.0': + resolution: {integrity: sha512-7/iF1orvIS9mxhKUqnmtMgm+OrSQ5acPwuvdQrm6ECgqbwPmC+Pw9cdke3sNfVN6pT2hbJ58+jP8BCThl5HXOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': - resolution: {integrity: sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.67.0': + resolution: {integrity: sha512-yy+OGys07IZOpOmYPZoObKyUQLkfxeQqeCypk+1jaZd8HGo77hzvU1Jg8X3+W75o+9lszOjBfg0nkGtlwYywXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.66.0': - resolution: {integrity: sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==} + '@oxfmt/binding-linux-arm-musleabihf@0.67.0': + resolution: {integrity: sha512-wPIeeigXgJpwNw3wydYRt3U9iN9Y/ejpOZuYL9IA7igxWs7LIQMOkhKxTumRvy6dIv0iXKk3RTw3Vmjg0i+2sg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.66.0': - resolution: {integrity: sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==} + '@oxfmt/binding-linux-arm64-gnu@0.67.0': + resolution: {integrity: sha512-0+XNxcdbkTfxdcD4qW6Ci9n+mBNJ8xTBumnxKvKBmRFOdx0Wf8/KiHjCJayooXmYkqRpRVd98Q5egvzx5BLSgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.66.0': - resolution: {integrity: sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==} + '@oxfmt/binding-linux-arm64-musl@0.67.0': + resolution: {integrity: sha512-I75LKPJyNOYUzkqAiAMIE31+Ye7xtQXZdoty1IXn4B+bw5Zpmez5wfG19ejGpNnS/BzQ7LFS+7jxuTPb+vHiZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.66.0': - resolution: {integrity: sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==} + '@oxfmt/binding-linux-ppc64-gnu@0.67.0': + resolution: {integrity: sha512-c2M5iRpe1QMZSRE/UvZoPdXBWb5Ic/ycvOyNiKCqPwQ/OyOKIMiJs02ynlNnjb7ZZJnRXYLmGcohoINOcwDK3w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.66.0': - resolution: {integrity: sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==} + '@oxfmt/binding-linux-riscv64-gnu@0.67.0': + resolution: {integrity: sha512-dQzzYlV24Udhfm5ECuSdgqRvFJU/CGHzcYYEO3dLM6W6+CHiBFrq9OjIllkdCcPhsoSQ8o223Dja84MOSzed9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.66.0': - resolution: {integrity: sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==} + '@oxfmt/binding-linux-riscv64-musl@0.67.0': + resolution: {integrity: sha512-rFNq1CgX4qMJANOq42LkAs90JE80GpiaEohAV2qn/gT2hGjQTW1zBO5zQBxArI4926pM1OSzo3CN0tBszGBIaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.66.0': - resolution: {integrity: sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==} + '@oxfmt/binding-linux-s390x-gnu@0.67.0': + resolution: {integrity: sha512-Sky6rEdz2o5IGq01lPhS12yEvDdChVEcaYrcLHkveh4Fx0qPjljE/Iul6SX/bRMl6lNc8J7J/mDQdzgBdA++Pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.66.0': - resolution: {integrity: sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==} + '@oxfmt/binding-linux-x64-gnu@0.67.0': + resolution: {integrity: sha512-vPXmlNORV8AZq2Ocxh07pxwMjfENUWCV/eZArnao0qC3NO/hDeTVkQvee7SJJUbIiF5PZbBa4kYmaXnu7Rk58w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.66.0': - resolution: {integrity: sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==} + '@oxfmt/binding-linux-x64-musl@0.67.0': + resolution: {integrity: sha512-x/WAtFqYtVr3vZ9ni8nr4kn9whSitg8fOljq/pZzBpxopRdY1BMLZCZkrbIbaBcYkm46qGbqVea2FCWmtQ2P9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.66.0': - resolution: {integrity: sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==} + '@oxfmt/binding-openharmony-arm64@0.67.0': + resolution: {integrity: sha512-eRw9Neh4/aA6i+q/R3WU1gGQINhVM0J4fXIm6t27caOamkr/37uAkp1IdBx4zlJH97hmXR63z/q9n5c5dN7MzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.66.0': - resolution: {integrity: sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==} + '@oxfmt/binding-win32-arm64-msvc@0.67.0': + resolution: {integrity: sha512-YIMvb+sGNYN2uc6+QK2HLPeEKM2vl7QZ5onQzpAJRb6pnf0DwUFP5R8tdS9R0l8hdUil2gu4Uxd0Yxrop0iT4w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.66.0': - resolution: {integrity: sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==} + '@oxfmt/binding-win32-ia32-msvc@0.67.0': + resolution: {integrity: sha512-LzmU9MyACPzwNDIK0ItMedHPz735Ug7ELWguxo4/kuy6zWuDoeglOAEFCY8jLg0PzRpFO3hDyLFe2Gu2eFDeGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.66.0': - resolution: {integrity: sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==} + '@oxfmt/binding-win32-x64-msvc@0.67.0': + resolution: {integrity: sha512-sbQOIDNLUEeVZcAJcSL5VURn7kfjvilPviody4Yl5n8lQCDtUm+C9oHTTwZS/m4d/Z6Vv3jNEiAofH932NPPCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -582,124 +582,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.81.0': - resolution: {integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==} + '@oxlint/binding-android-arm-eabi@1.82.0': + resolution: {integrity: sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.81.0': - resolution: {integrity: sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==} + '@oxlint/binding-android-arm64@1.82.0': + resolution: {integrity: sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.81.0': - resolution: {integrity: sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==} + '@oxlint/binding-darwin-arm64@1.82.0': + resolution: {integrity: sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.81.0': - resolution: {integrity: sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==} + '@oxlint/binding-darwin-x64@1.82.0': + resolution: {integrity: sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.81.0': - resolution: {integrity: sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==} + '@oxlint/binding-freebsd-x64@1.82.0': + resolution: {integrity: sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.81.0': - resolution: {integrity: sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==} + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': + resolution: {integrity: sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.81.0': - resolution: {integrity: sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==} + '@oxlint/binding-linux-arm-musleabihf@1.82.0': + resolution: {integrity: sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.81.0': - resolution: {integrity: sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==} + '@oxlint/binding-linux-arm64-gnu@1.82.0': + resolution: {integrity: sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.81.0': - resolution: {integrity: sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==} + '@oxlint/binding-linux-arm64-musl@1.82.0': + resolution: {integrity: sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.81.0': - resolution: {integrity: sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==} + '@oxlint/binding-linux-ppc64-gnu@1.82.0': + resolution: {integrity: sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.81.0': - resolution: {integrity: sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==} + '@oxlint/binding-linux-riscv64-gnu@1.82.0': + resolution: {integrity: sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.81.0': - resolution: {integrity: sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==} + '@oxlint/binding-linux-riscv64-musl@1.82.0': + resolution: {integrity: sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.81.0': - resolution: {integrity: sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==} + '@oxlint/binding-linux-s390x-gnu@1.82.0': + resolution: {integrity: sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.81.0': - resolution: {integrity: sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==} + '@oxlint/binding-linux-x64-gnu@1.82.0': + resolution: {integrity: sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.81.0': - resolution: {integrity: sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==} + '@oxlint/binding-linux-x64-musl@1.82.0': + resolution: {integrity: sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.81.0': - resolution: {integrity: sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==} + '@oxlint/binding-openharmony-arm64@1.82.0': + resolution: {integrity: sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.81.0': - resolution: {integrity: sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==} + '@oxlint/binding-win32-arm64-msvc@1.82.0': + resolution: {integrity: sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.81.0': - resolution: {integrity: sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==} + '@oxlint/binding-win32-ia32-msvc@1.82.0': + resolution: {integrity: sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.81.0': - resolution: {integrity: sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==} + '@oxlint/binding-win32-x64-msvc@1.82.0': + resolution: {integrity: sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1098,8 +1098,8 @@ packages: magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} - miniflare@5.20260903.0-alpha: - resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==} + miniflare@5.20260911.0-alpha: + resolution: {integrity: sha512-CRieJmvHx+7rNqnA5SKdsYsER6rfkUIE/jruIUw+fLhsQ4sORfuMtr3+FQzsQ9/y8lhk061V4Fl1DFdHiyBB6g==} engines: {node: '>=22.0.0'} nanoid@3.3.18: @@ -1111,8 +1111,8 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - oxfmt@0.66.0: - resolution: {integrity: sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==} + oxfmt@0.67.0: + resolution: {integrity: sha512-vV7sSiPsaO0mSxdoUdayipVDFPzW/UQ+hrezEHa20+Tx1dnMdZLSRHMT0PdS67FFbhd74M1n08asW21aLGeCrA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1128,8 +1128,8 @@ packages: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true - oxlint@1.81.0: - resolution: {integrity: sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==} + oxlint@1.82.0: + resolution: {integrity: sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1168,9 +1168,14 @@ packages: engines: {node: '>=10'} hasBin: true - sharp@0.35.2: - resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1201,8 +1206,8 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} tslib@2.8.1: @@ -1314,17 +1319,17 @@ packages: engines: {node: '>=8'} hasBin: true - workerd@1.20260903.1: - resolution: {integrity: sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==} + workerd@1.20260911.1: + resolution: {integrity: sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==} engines: {node: '>=16'} hasBin: true - wrangler@4.129.0: - resolution: {integrity: sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==} + wrangler@4.131.1: + resolution: {integrity: sha512-1u5FMdJAn6UOcL02cVsIITcnHrk6mC7N+RF10EkVhPL18R/o9g5BZb4PCjByL+3AsRP5wQpppCIPHhYPRmIJwg==} engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^5.20260903.1 + '@cloudflare/workers-types': ^5.20260911.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -1351,28 +1356,28 @@ snapshots: '@cloudflare/kv-asset-handler@0.5.0': {} - '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1)': + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260911.1)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260903.1 + workerd: 1.20260911.1 - '@cloudflare/workerd-darwin-64@1.20260903.1': + '@cloudflare/workerd-darwin-64@1.20260911.1': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260903.1': + '@cloudflare/workerd-darwin-arm64@1.20260911.1': optional: true - '@cloudflare/workerd-linux-64@1.20260903.1': + '@cloudflare/workerd-linux-64@1.20260911.1': optional: true - '@cloudflare/workerd-linux-arm64@1.20260903.1': + '@cloudflare/workerd-linux-arm64@1.20260911.1': optional: true - '@cloudflare/workerd-windows-64@1.20260903.1': + '@cloudflare/workerd-windows-64@1.20260911.1': optional: true - '@cloudflare/workers-types@5.20260904.1': {} + '@cloudflare/workers-types@5.20260911.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -1463,108 +1468,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.2': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.2': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.2': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.1': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.1': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.1': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.1': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.1': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.1': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.1': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.1': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.1': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.2': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.2': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.2': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.2': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.2': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.2': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.2': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.2': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.2': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.2': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.2 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.2': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.2': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.2': + '@img/sharp-win32-x64@0.35.4': optional: true '@jridgewell/resolve-uri@3.1.2': {} @@ -1583,61 +1588,61 @@ snapshots: '@oxc-project/types@0.147.0': {} - '@oxfmt/binding-android-arm-eabi@0.66.0': + '@oxfmt/binding-android-arm-eabi@0.67.0': optional: true - '@oxfmt/binding-android-arm64@0.66.0': + '@oxfmt/binding-android-arm64@0.67.0': optional: true - '@oxfmt/binding-darwin-arm64@0.66.0': + '@oxfmt/binding-darwin-arm64@0.67.0': optional: true - '@oxfmt/binding-darwin-x64@0.66.0': + '@oxfmt/binding-darwin-x64@0.67.0': optional: true - '@oxfmt/binding-freebsd-x64@0.66.0': + '@oxfmt/binding-freebsd-x64@0.67.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.67.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + '@oxfmt/binding-linux-arm-musleabihf@0.67.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.66.0': + '@oxfmt/binding-linux-arm64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.66.0': + '@oxfmt/binding-linux-arm64-musl@0.67.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + '@oxfmt/binding-linux-ppc64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + '@oxfmt/binding-linux-riscv64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.66.0': + '@oxfmt/binding-linux-riscv64-musl@0.67.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.66.0': + '@oxfmt/binding-linux-s390x-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.66.0': + '@oxfmt/binding-linux-x64-gnu@0.67.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.66.0': + '@oxfmt/binding-linux-x64-musl@0.67.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.66.0': + '@oxfmt/binding-openharmony-arm64@0.67.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.66.0': + '@oxfmt/binding-win32-arm64-msvc@0.67.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.66.0': + '@oxfmt/binding-win32-ia32-msvc@0.67.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.66.0': + '@oxfmt/binding-win32-x64-msvc@0.67.0': optional: true '@oxlint-tsgolint/darwin-arm64@7.0.2001': @@ -1658,61 +1663,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true - '@oxlint/binding-android-arm-eabi@1.81.0': + '@oxlint/binding-android-arm-eabi@1.82.0': optional: true - '@oxlint/binding-android-arm64@1.81.0': + '@oxlint/binding-android-arm64@1.82.0': optional: true - '@oxlint/binding-darwin-arm64@1.81.0': + '@oxlint/binding-darwin-arm64@1.82.0': optional: true - '@oxlint/binding-darwin-x64@1.81.0': + '@oxlint/binding-darwin-x64@1.82.0': optional: true - '@oxlint/binding-freebsd-x64@1.81.0': + '@oxlint/binding-freebsd-x64@1.82.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + '@oxlint/binding-linux-arm-gnueabihf@1.82.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.81.0': + '@oxlint/binding-linux-arm-musleabihf@1.82.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.81.0': + '@oxlint/binding-linux-arm64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.81.0': + '@oxlint/binding-linux-arm64-musl@1.82.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.81.0': + '@oxlint/binding-linux-ppc64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.81.0': + '@oxlint/binding-linux-riscv64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.81.0': + '@oxlint/binding-linux-riscv64-musl@1.82.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.81.0': + '@oxlint/binding-linux-s390x-gnu@1.82.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.81.0': + '@oxlint/binding-linux-x64-gnu@1.82.0': optional: true - '@oxlint/binding-linux-x64-musl@1.81.0': + '@oxlint/binding-linux-x64-musl@1.82.0': optional: true - '@oxlint/binding-openharmony-arm64@1.81.0': + '@oxlint/binding-openharmony-arm64@1.82.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.81.0': + '@oxlint/binding-win32-arm64-msvc@1.82.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.81.0': + '@oxlint/binding-win32-ia32-msvc@1.82.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.81.0': + '@oxlint/binding-win32-x64-msvc@1.82.0': optional: true '@poppinss/colors@4.1.6': @@ -1973,15 +1978,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 - miniflare@5.20260903.0-alpha: + miniflare@5.20260911.0-alpha: dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.2 + sharp: 0.35.4 undici: 8.10.1 - workerd: 1.20260903.1 + workerd: 1.20260911.1 ws: 8.21.3 youch: 4.1.0-beta.10 transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate @@ -1989,29 +1995,29 @@ snapshots: obug@2.1.4: {} - oxfmt@0.66.0: + oxfmt@0.67.0: dependencies: - tinypool: 2.1.0 + tinypool: 2.1.2 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.66.0 - '@oxfmt/binding-android-arm64': 0.66.0 - '@oxfmt/binding-darwin-arm64': 0.66.0 - '@oxfmt/binding-darwin-x64': 0.66.0 - '@oxfmt/binding-freebsd-x64': 0.66.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.66.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.66.0 - '@oxfmt/binding-linux-arm64-gnu': 0.66.0 - '@oxfmt/binding-linux-arm64-musl': 0.66.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.66.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.66.0 - '@oxfmt/binding-linux-riscv64-musl': 0.66.0 - '@oxfmt/binding-linux-s390x-gnu': 0.66.0 - '@oxfmt/binding-linux-x64-gnu': 0.66.0 - '@oxfmt/binding-linux-x64-musl': 0.66.0 - '@oxfmt/binding-openharmony-arm64': 0.66.0 - '@oxfmt/binding-win32-arm64-msvc': 0.66.0 - '@oxfmt/binding-win32-ia32-msvc': 0.66.0 - '@oxfmt/binding-win32-x64-msvc': 0.66.0 + '@oxfmt/binding-android-arm-eabi': 0.67.0 + '@oxfmt/binding-android-arm64': 0.67.0 + '@oxfmt/binding-darwin-arm64': 0.67.0 + '@oxfmt/binding-darwin-x64': 0.67.0 + '@oxfmt/binding-freebsd-x64': 0.67.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.67.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.67.0 + '@oxfmt/binding-linux-arm64-gnu': 0.67.0 + '@oxfmt/binding-linux-arm64-musl': 0.67.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.67.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.67.0 + '@oxfmt/binding-linux-riscv64-musl': 0.67.0 + '@oxfmt/binding-linux-s390x-gnu': 0.67.0 + '@oxfmt/binding-linux-x64-gnu': 0.67.0 + '@oxfmt/binding-linux-x64-musl': 0.67.0 + '@oxfmt/binding-openharmony-arm64': 0.67.0 + '@oxfmt/binding-win32-arm64-msvc': 0.67.0 + '@oxfmt/binding-win32-ia32-msvc': 0.67.0 + '@oxfmt/binding-win32-x64-msvc': 0.67.0 oxlint-tsgolint@7.0.2001: optionalDependencies: @@ -2022,27 +2028,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 - oxlint@1.81.0(oxlint-tsgolint@7.0.2001): + oxlint@1.82.0(oxlint-tsgolint@7.0.2001): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.81.0 - '@oxlint/binding-android-arm64': 1.81.0 - '@oxlint/binding-darwin-arm64': 1.81.0 - '@oxlint/binding-darwin-x64': 1.81.0 - '@oxlint/binding-freebsd-x64': 1.81.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.81.0 - '@oxlint/binding-linux-arm-musleabihf': 1.81.0 - '@oxlint/binding-linux-arm64-gnu': 1.81.0 - '@oxlint/binding-linux-arm64-musl': 1.81.0 - '@oxlint/binding-linux-ppc64-gnu': 1.81.0 - '@oxlint/binding-linux-riscv64-gnu': 1.81.0 - '@oxlint/binding-linux-riscv64-musl': 1.81.0 - '@oxlint/binding-linux-s390x-gnu': 1.81.0 - '@oxlint/binding-linux-x64-gnu': 1.81.0 - '@oxlint/binding-linux-x64-musl': 1.81.0 - '@oxlint/binding-openharmony-arm64': 1.81.0 - '@oxlint/binding-win32-arm64-msvc': 1.81.0 - '@oxlint/binding-win32-ia32-msvc': 1.81.0 - '@oxlint/binding-win32-x64-msvc': 1.81.0 + '@oxlint/binding-android-arm-eabi': 1.82.0 + '@oxlint/binding-android-arm64': 1.82.0 + '@oxlint/binding-darwin-arm64': 1.82.0 + '@oxlint/binding-darwin-x64': 1.82.0 + '@oxlint/binding-freebsd-x64': 1.82.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.82.0 + '@oxlint/binding-linux-arm-musleabihf': 1.82.0 + '@oxlint/binding-linux-arm64-gnu': 1.82.0 + '@oxlint/binding-linux-arm64-musl': 1.82.0 + '@oxlint/binding-linux-ppc64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-gnu': 1.82.0 + '@oxlint/binding-linux-riscv64-musl': 1.82.0 + '@oxlint/binding-linux-s390x-gnu': 1.82.0 + '@oxlint/binding-linux-x64-gnu': 1.82.0 + '@oxlint/binding-linux-x64-musl': 1.82.0 + '@oxlint/binding-openharmony-arm64': 1.82.0 + '@oxlint/binding-win32-arm64-msvc': 1.82.0 + '@oxlint/binding-win32-ia32-msvc': 1.82.0 + '@oxlint/binding-win32-x64-msvc': 1.82.0 oxlint-tsgolint: 7.0.2001 path-to-regexp@6.3.0: {} @@ -2082,37 +2088,37 @@ snapshots: semver@7.8.5: {} - sharp@0.35.2: + sharp@0.35.4: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.2 - '@img/sharp-darwin-x64': 0.35.2 - '@img/sharp-freebsd-wasm32': 0.35.2 - '@img/sharp-libvips-darwin-arm64': 1.3.1 - '@img/sharp-libvips-darwin-x64': 1.3.1 - '@img/sharp-libvips-linux-arm': 1.3.1 - '@img/sharp-libvips-linux-arm64': 1.3.1 - '@img/sharp-libvips-linux-ppc64': 1.3.1 - '@img/sharp-libvips-linux-riscv64': 1.3.1 - '@img/sharp-libvips-linux-s390x': 1.3.1 - '@img/sharp-libvips-linux-x64': 1.3.1 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 - '@img/sharp-libvips-linuxmusl-x64': 1.3.1 - '@img/sharp-linux-arm': 0.35.2 - '@img/sharp-linux-arm64': 0.35.2 - '@img/sharp-linux-ppc64': 0.35.2 - '@img/sharp-linux-riscv64': 0.35.2 - '@img/sharp-linux-s390x': 0.35.2 - '@img/sharp-linux-x64': 0.35.2 - '@img/sharp-linuxmusl-arm64': 0.35.2 - '@img/sharp-linuxmusl-x64': 0.35.2 - '@img/sharp-webcontainers-wasm32': 0.35.2 - '@img/sharp-win32-arm64': 0.35.2 - '@img/sharp-win32-ia32': 0.35.2 - '@img/sharp-win32-x64': 0.35.2 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 siginfo@2.0.0: {} @@ -2133,7 +2139,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - tinypool@2.1.0: {} + tinypool@2.1.2: {} tslib@2.8.1: optional: true @@ -2204,28 +2210,29 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - workerd@1.20260903.1: + workerd@1.20260911.1: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260903.1 - '@cloudflare/workerd-darwin-arm64': 1.20260903.1 - '@cloudflare/workerd-linux-64': 1.20260903.1 - '@cloudflare/workerd-linux-arm64': 1.20260903.1 - '@cloudflare/workerd-windows-64': 1.20260903.1 + '@cloudflare/workerd-darwin-64': 1.20260911.1 + '@cloudflare/workerd-darwin-arm64': 1.20260911.1 + '@cloudflare/workerd-linux-64': 1.20260911.1 + '@cloudflare/workerd-linux-arm64': 1.20260911.1 + '@cloudflare/workerd-windows-64': 1.20260911.1 - wrangler@4.129.0(@cloudflare/workers-types@5.20260904.1): + wrangler@4.131.1(@cloudflare/workers-types@5.20260911.1): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1) + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260911.1) blake3-wasm: 2.1.5 esbuild: 0.28.2 - miniflare: 5.20260903.0-alpha + miniflare: 5.20260911.0-alpha path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260903.1 + workerd: 1.20260911.1 optionalDependencies: - '@cloudflare/workers-types': 5.20260904.1 + '@cloudflare/workers-types': 5.20260911.1 fsevents: 2.3.3 transitivePeerDependencies: + - '@types/node' - bufferutil - utf-8-validate diff --git a/safety-profiles/agent-safe.yaml b/safety-profiles/agent-safe.yaml index b03798666..ae488fb9b 100644 --- a/safety-profiles/agent-safe.yaml +++ b/safety-profiles/agent-safe.yaml @@ -212,6 +212,7 @@ sheets: create: false copy: false add-tab: true + duplicate-tab: false rename-tab: true delete-tab: false chart: @@ -236,6 +237,7 @@ slides: update-notes: false replace-slide: false insert-text: false + paragraph-style: false replace-text: false chat: diff --git a/safety-profiles/readonly.yaml b/safety-profiles/readonly.yaml index 2c715314f..6745d5190 100644 --- a/safety-profiles/readonly.yaml +++ b/safety-profiles/readonly.yaml @@ -217,6 +217,7 @@ sheets: create: false copy: false add-tab: false + duplicate-tab: false rename-tab: false delete-tab: false chart: @@ -241,6 +242,7 @@ slides: update-notes: false replace-slide: false insert-text: false + paragraph-style: false replace-text: false chat: