From 06b22ddfdbbc5859da7720e57fc406220abfc105 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:23:11 -0400 Subject: [PATCH 1/6] feat(sheets): add reorder-tab command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #603. Surfaces spreadsheets.batchUpdate -> updateSheetProperties with field mask `index` as a standalone CLI subcommand. add-tab, rename-tab, and delete-tab were already exposed; reordering was the missing primitive. Usage: gog sheets reorder-tab --tab= --to=N Notes: - --tab accepts a literal tab title OR a numeric sheet ID; numeric IDs are resolved without a name lookup so the command works on tabs that share titles or have empty titles. - --to is the destination 0-based tab index; --to=0 is the leftmost position and is force-sent over the wire (Index would otherwise be elided as Go's zero value, leaving the API call a no-op). - Aliases: move-tab, reorder-sheet, move-sheet. Tests cover: name → sheetId resolution, numeric sheetId pass-through, --to=0 reaching the wire as "index":0 (raw JSON inspection), unknown- tab error, and the --to=-1 guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cmd/sheets.go | 1 + internal/cmd/sheets_reorder_tab.go | 131 ++++++++++++++ internal/cmd/sheets_reorder_tab_test.go | 221 ++++++++++++++++++++++++ 3 files changed, 353 insertions(+) create mode 100644 internal/cmd/sheets_reorder_tab.go create mode 100644 internal/cmd/sheets_reorder_tab_test.go diff --git a/internal/cmd/sheets.go b/internal/cmd/sheets.go index 5ee4d263..2f6f2165 100644 --- a/internal/cmd/sheets.go +++ b/internal/cmd/sheets.go @@ -56,6 +56,7 @@ type SheetsCmd struct { AddTab SheetsAddTabCmd `cmd:"" name:"add-tab" aliases:"add-sheet" help:"Add a new tab/sheet to 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"` } type SheetsExportCmd struct { diff --git a/internal/cmd/sheets_reorder_tab.go b/internal/cmd/sheets_reorder_tab.go new file mode 100644 index 00000000..f4254f49 --- /dev/null +++ b/internal/cmd/sheets_reorder_tab.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "context" + "os" + "strconv" + "strings" + + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// SheetsReorderTabCmd moves a tab to a specific 0-based position in the +// spreadsheet via spreadsheets.batchUpdate -> updateSheetProperties with field +// mask `index`. Existing tab management (add/rename/delete) does not expose +// this — see #603. +type SheetsReorderTabCmd struct { + SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` + Tab string `name:"tab" required:"" help:"Target tab by name or numeric sheet ID (see sheets metadata)"` + To *int64 `name:"to" required:"" help:"Destination 0-based tab index"` +} + +func (c *SheetsReorderTabCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + + spreadsheetID := normalizeGoogleID(strings.TrimSpace(c.SpreadsheetID)) + tab := strings.TrimSpace(c.Tab) + if spreadsheetID == "" { + return usage("empty spreadsheetId") + } + if tab == "" { + return usage("--tab is required") + } + if c.To == nil { + return usage("--to is required") + } + if *c.To < 0 { + return usage("--to must be >= 0") + } + + if err := dryRunExit(ctx, flags, "sheets.reorder-tab", map[string]any{ + "spreadsheet_id": spreadsheetID, + "tab": tab, + "to": *c.To, + }); err != nil { + return err + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + svc, err := newSheetsService(ctx, account) + if err != nil { + return err + } + + sheetID, resolvedTitle, err := resolveSheetTabID(ctx, svc, spreadsheetID, tab) + if err != nil { + return err + } + + req := &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{ + { + UpdateSheetProperties: &sheets.UpdateSheetPropertiesRequest{ + Properties: &sheets.SheetProperties{ + SheetId: sheetID, + Index: *c.To, + ForceSendFields: []string{"Index"}, + }, + Fields: "index", + }, + }, + }, + } + + if _, err := svc.Spreadsheets.BatchUpdate(spreadsheetID, req).Context(ctx).Do(); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + payload := map[string]any{ + "spreadsheetId": spreadsheetID, + "sheetId": sheetID, + "index": *c.To, + } + if resolvedTitle != "" { + payload["title"] = resolvedTitle + } + return outfmt.WriteJSON(ctx, os.Stdout, payload) + } + + if resolvedTitle != "" { + u.Out().Linef("Moved tab %q (sheetId %d) to index %d in spreadsheet %s", resolvedTitle, sheetID, *c.To, spreadsheetID) + } else { + u.Out().Linef("Moved sheetId %d to index %d in spreadsheet %s", sheetID, *c.To, spreadsheetID) + } + return nil +} + +// resolveSheetTabID accepts either a tab title or a numeric sheet ID and +// returns (sheetID, title). When the caller passed a numeric ID, title is +// empty unless we happen to find a matching tab while listing IDs. +func resolveSheetTabID(ctx context.Context, svc *sheets.Service, spreadsheetID, tab string) (int64, string, error) { + if id, err := strconv.ParseInt(tab, 10, 64); err == nil { + // Numeric — try to enrich with title for nicer output, but accept it + // even if the GET fails or the tab isn't found in the map. + if titles, mapErr := fetchSheetIDMap(ctx, svc, spreadsheetID); mapErr == nil { + for title, sheetID := range titles { + if sheetID == id { + return id, title, nil + } + } + } + return id, "", nil + } + + sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID) + if err != nil { + return 0, "", err + } + sheetID, ok := sheetIDs[tab] + if !ok { + return 0, "", usagef("unknown tab %q", tab) + } + return sheetID, tab, nil +} diff --git a/internal/cmd/sheets_reorder_tab_test.go b/internal/cmd/sheets_reorder_tab_test.go new file mode 100644 index 00000000..8d023e12 --- /dev/null +++ b/internal/cmd/sheets_reorder_tab_test.go @@ -0,0 +1,221 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" + + "github.com/steipete/gogcli/internal/ui" +) + +func newSheetsTestServer(t *testing.T, batchUpdateCapture *sheets.BatchUpdateSpreadsheetRequest, sheetsCatalog []map[string]any) (*sheets.Service, func()) { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + + switch { + case strings.HasPrefix(path, "/spreadsheets/s1") && !strings.Contains(path, ":batchUpdate") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "sheets": sheetsCatalog, + }) + case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost: + if err := json.NewDecoder(r.Body).Decode(batchUpdateCapture); err != nil { + t.Fatalf("decode batchUpdate: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + return svc, func() {} +} + +func newSheetsCmdContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func TestSheetsReorderTabCmd_ResolvesByName(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 11, "title": "First", "index": 0}}, + {"properties": map[string]any{"sheetId": 22, "title": "Second", "index": 1}}, + {"properties": map[string]any{"sheetId": 33, "title": "Third", "index": 2}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "Third", "--to", "0"}, ctx, flags); err != nil { + t.Fatalf("reorder-tab: %v", err) + } + + if len(captured.Requests) != 1 { + t.Fatalf("expected 1 batchUpdate request, got %d", len(captured.Requests)) + } + req := captured.Requests[0].UpdateSheetProperties + if req == nil { + t.Fatalf("expected UpdateSheetProperties, got %#v", captured.Requests[0]) + } + if req.Properties == nil || req.Properties.SheetId != 33 { + t.Fatalf("expected sheetId=33 (resolved from \"Third\"), got %#v", req.Properties) + } + if req.Properties.Index != 0 { + t.Fatalf("expected target index 0, got %d", req.Properties.Index) + } + if req.Fields != "index" { + t.Fatalf("expected Fields=\"index\", got %q", req.Fields) + } +} + +func TestSheetsReorderTabCmd_AcceptsNumericSheetID(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 99, "title": "Only", "index": 0}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "99", "--to", "5"}, ctx, flags); err != nil { + t.Fatalf("reorder-tab: %v", err) + } + + req := captured.Requests[0].UpdateSheetProperties + if req == nil || req.Properties == nil { + t.Fatalf("expected UpdateSheetProperties, got %#v", captured.Requests[0]) + } + if req.Properties.SheetId != 99 { + t.Fatalf("expected numeric sheetId passed through, got %d", req.Properties.SheetId) + } + if req.Properties.Index != 5 { + t.Fatalf("expected target index 5, got %d", req.Properties.Index) + } +} + +func TestSheetsReorderTabCmd_IndexZeroIsSerialized(t *testing.T) { + // Index=0 is the leftmost position and is also Go's zero value for int64. + // Without ForceSendFields the JSON wire format would omit it and the API + // would treat the call as a no-op move-to-current-position. We can't see + // ForceSendFields after a round-trip decode, so we capture the raw request + // body and assert "index":0 appears in the JSON. + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var rawBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + switch { + case strings.HasPrefix(path, "/spreadsheets/s1") && !strings.Contains(path, ":batchUpdate") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "sheets": []map[string]any{ + {"properties": map[string]any{"sheetId": 11, "title": "First", "index": 0}}, + {"properties": map[string]any{"sheetId": 22, "title": "Second", "index": 1}}, + }, + }) + case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + rawBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "Second", "--to", "0"}, ctx, flags); err != nil { + t.Fatalf("reorder-tab: %v", err) + } + + if !strings.Contains(string(rawBody), `"index":0`) { + t.Fatalf("expected raw body to contain \"index\":0 (ForceSendFields was not effective); body = %s", rawBody) + } + if !strings.Contains(string(rawBody), `"fields":"index"`) { + t.Fatalf("expected raw body to contain \"fields\":\"index\"; body = %s", rawBody) + } +} + +func TestSheetsReorderTabCmd_UnknownTabName(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 1, "title": "Sheet1", "index": 0}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "Nope", "--to", "1"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), `unknown tab "Nope"`) { + t.Fatalf("expected unknown-tab error, got %v", err) + } +} + +func TestSheetsReorderTabCmd_NegativeIndexRejected(t *testing.T) { + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "x", "--to=-1"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "--to must be >= 0") { + t.Fatalf("expected --to validation error, got %v", err) + } +} From 82c5d5aa7c7b289b671360d976549e6042276350 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Tue, 19 May 2026 20:39:11 -0400 Subject: [PATCH 2/6] docs: changelog + regen for #603 sheets reorder-tab Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + docs/commands.generated.md | 1 + docs/commands/README.md | 1 + docs/commands/gog-sheets-reorder-tab.md | 45 +++++++++++++++++++++++++ docs/commands/gog-sheets.md | 1 + 5 files changed, 49 insertions(+) create mode 100644 docs/commands/gog-sheets-reorder-tab.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d91e77d6..6aa1c366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Auth: add gog zoom auth setup / doctor for Zoom S2S OAuth credential storage. (#590) — thanks @mvanhorn. - Docs: add `gog docs insert-page-break [--index N | --at-end] [--tab=STRING]` to insert a Google Docs page break directly via `InsertPageBreakRequest` — markdown has no native page-break construct, so this is the only path for multi-page deliverables. Aliases: `page-break`, `pb`. (#604) - Docs: add `--heading-level N` (1..6 shortcut) and `--named-style NAME` (full enum) to `gog docs format` so existing paragraphs can be promoted to `HEADING_1`..`HEADING_6`, `TITLE`, `SUBTITLE`, or `NORMAL_TEXT`. Both set `paragraphStyle.namedStyleType` on the existing UpdateParagraphStyle request and compose cleanly with `--alignment` / `--line-spacing`. (#605) +- Sheets: add `gog sheets reorder-tab --tab= --to=N` to move a tab to a specific 0-based position via `updateSheetProperties` with field mask `index`. `--tab` accepts a title or a numeric sheet ID; `--to=0` is force-sent so the leftmost target reaches the wire as `"index":0`. Aliases: `move-tab`, `reorder-sheet`, `move-sheet`. (#603) ### Fixed diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 33934568..333e63c9 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -499,6 +499,7 @@ Generated from `gog schema --json`. - [`gog sheets (sheet) raw [flags]`](commands/gog-sheets-raw.md) - Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) - [`gog sheets (sheet) read-format (get-format,format-read) [flags]`](commands/gog-sheets-read-format.md) - Read cell formatting from a range - [`gog sheets (sheet) rename-tab (rename-sheet) `](commands/gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet + - [`gog sheets (sheet) reorder-tab (move-tab,reorder-sheet,move-sheet) --tab=STRING --to=TO `](commands/gog-sheets-reorder-tab.md) - Move a tab/sheet to a specific 0-based position in the spreadsheet - [`gog sheets (sheet) resize-columns [flags]`](commands/gog-sheets-resize-columns.md) - Resize sheet columns - [`gog sheets (sheet) resize-rows [flags]`](commands/gog-sheets-resize-rows.md) - Resize sheet rows - [`gog sheets (sheet) table (tables) `](commands/gog-sheets-table.md) - Manage Google Sheets tables diff --git a/docs/commands/README.md b/docs/commands/README.md index d0734147..bade24fc 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -550,6 +550,7 @@ Generated pages: 565. - [gog sheets raw](gog-sheets-raw.md) - Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) - [gog sheets read-format](gog-sheets-read-format.md) - Read cell formatting from a range - [gog sheets rename-tab](gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet + - [gog sheets reorder-tab](gog-sheets-reorder-tab.md) - Move a tab/sheet to a specific 0-based position in the spreadsheet - [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns - [gog sheets resize-rows](gog-sheets-resize-rows.md) - Resize sheet rows - [gog sheets table](gog-sheets-table.md) - Manage Google Sheets tables diff --git a/docs/commands/gog-sheets-reorder-tab.md b/docs/commands/gog-sheets-reorder-tab.md new file mode 100644 index 00000000..fdb76172 --- /dev/null +++ b/docs/commands/gog-sheets-reorder-tab.md @@ -0,0 +1,45 @@ +# `gog sheets reorder-tab` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Move a tab/sheet to a specific 0-based position in the spreadsheet + +## Usage + +```bash +gog sheets (sheet) reorder-tab (move-tab,reorder-sheet,move-sheet) --tab=STRING --to=TO +``` + +## Parent + +- [gog sheets](gog-sheets.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/ads/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `-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) | +| `--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. | +| `--tab` | `string` | | Target tab by name or numeric sheet ID (see sheets metadata) | +| `--to` | `*int64` | | Destination 0-based tab index | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog sheets](gog-sheets.md) +- [Command index](README.md) diff --git a/docs/commands/gog-sheets.md b/docs/commands/gog-sheets.md index 7667172e..50894f4a 100644 --- a/docs/commands/gog-sheets.md +++ b/docs/commands/gog-sheets.md @@ -40,6 +40,7 @@ gog sheets (sheet) [flags] - [gog sheets raw](gog-sheets-raw.md) - Dump raw Google Sheets API response as JSON (Spreadsheets.Get; lossless; for scripting and LLM consumption) - [gog sheets read-format](gog-sheets-read-format.md) - Read cell formatting from a range - [gog sheets rename-tab](gog-sheets-rename-tab.md) - Rename a tab/sheet in a spreadsheet +- [gog sheets reorder-tab](gog-sheets-reorder-tab.md) - Move a tab/sheet to a specific 0-based position in the spreadsheet - [gog sheets resize-columns](gog-sheets-resize-columns.md) - Resize sheet columns - [gog sheets resize-rows](gog-sheets-resize-rows.md) - Resize sheet rows - [gog sheets table](gog-sheets-table.md) - Manage Google Sheets tables From 4718dc5d2f83a1b1a8877b01b233558da4b5972a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 21:49:42 +0100 Subject: [PATCH 3/6] chore(sheets): refresh reorder tab docs --- docs/commands/README.md | 2 +- internal/cmd/sheets_reorder_tab.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/commands/README.md b/docs/commands/README.md index bade24fc..07fe2c20 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: 565. +Generated pages: 566. ## Top-level Commands diff --git a/internal/cmd/sheets_reorder_tab.go b/internal/cmd/sheets_reorder_tab.go index f4254f49..85651f83 100644 --- a/internal/cmd/sheets_reorder_tab.go +++ b/internal/cmd/sheets_reorder_tab.go @@ -15,7 +15,7 @@ import ( // SheetsReorderTabCmd moves a tab to a specific 0-based position in the // spreadsheet via spreadsheets.batchUpdate -> updateSheetProperties with field // mask `index`. Existing tab management (add/rename/delete) does not expose -// this — see #603. +// this; see #603. type SheetsReorderTabCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` Tab string `name:"tab" required:"" help:"Target tab by name or numeric sheet ID (see sheets metadata)"` @@ -107,7 +107,7 @@ func (c *SheetsReorderTabCmd) Run(ctx context.Context, flags *RootFlags) error { // empty unless we happen to find a matching tab while listing IDs. func resolveSheetTabID(ctx context.Context, svc *sheets.Service, spreadsheetID, tab string) (int64, string, error) { if id, err := strconv.ParseInt(tab, 10, 64); err == nil { - // Numeric — try to enrich with title for nicer output, but accept it + // Numeric: try to enrich with title for nicer output, but accept it // even if the GET fails or the tab isn't found in the map. if titles, mapErr := fetchSheetIDMap(ctx, svc, spreadsheetID); mapErr == nil { for title, sheetID := range titles { From 7f276d079150a6a3219608c5fceeb61f067efdfd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 21:56:02 +0100 Subject: [PATCH 4/6] fix(sheets): honor final reorder tab indexes --- docs/commands/gog-sheets-reorder-tab.md | 2 +- internal/cmd/sheets_reorder_tab.go | 71 +++++++++++++--------- internal/cmd/sheets_reorder_tab_test.go | 81 ++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 34 deletions(-) diff --git a/docs/commands/gog-sheets-reorder-tab.md b/docs/commands/gog-sheets-reorder-tab.md index fdb76172..4134b93c 100644 --- a/docs/commands/gog-sheets-reorder-tab.md +++ b/docs/commands/gog-sheets-reorder-tab.md @@ -34,7 +34,7 @@ gog sheets (sheet) reorder-tab (move-tab,reorder-sheet,move-sheet) --tab=STRING | `--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. | | `--tab` | `string` | | Target tab by name or numeric sheet ID (see sheets metadata) | -| `--to` | `*int64` | | Destination 0-based tab index | +| `--to` | `*int64` | | Destination final 0-based tab index | | `-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/internal/cmd/sheets_reorder_tab.go b/internal/cmd/sheets_reorder_tab.go index 85651f83..fd13fac2 100644 --- a/internal/cmd/sheets_reorder_tab.go +++ b/internal/cmd/sheets_reorder_tab.go @@ -19,7 +19,7 @@ import ( type SheetsReorderTabCmd struct { SpreadsheetID string `arg:"" name:"spreadsheetId" help:"Spreadsheet ID"` Tab string `name:"tab" required:"" help:"Target tab by name or numeric sheet ID (see sheets metadata)"` - To *int64 `name:"to" required:"" help:"Destination 0-based tab index"` + To *int64 `name:"to" required:"" help:"Destination final 0-based tab index"` } func (c *SheetsReorderTabCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -58,18 +58,26 @@ func (c *SheetsReorderTabCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - sheetID, resolvedTitle, err := resolveSheetTabID(ctx, svc, spreadsheetID, tab) + target, err := resolveSheetTab(ctx, svc, spreadsheetID, tab) if err != nil { return err } + if *c.To >= int64(target.Count) { + return usagef("--to must be between 0 and %d", target.Count-1) + } + + apiIndex := *c.To + if *c.To > target.Index { + apiIndex++ + } req := &sheets.BatchUpdateSpreadsheetRequest{ Requests: []*sheets.Request{ { UpdateSheetProperties: &sheets.UpdateSheetPropertiesRequest{ Properties: &sheets.SheetProperties{ - SheetId: sheetID, - Index: *c.To, + SheetId: target.ID, + Index: apiIndex, ForceSendFields: []string{"Index"}, }, Fields: "index", @@ -85,47 +93,50 @@ func (c *SheetsReorderTabCmd) Run(ctx context.Context, flags *RootFlags) error { if outfmt.IsJSON(ctx) { payload := map[string]any{ "spreadsheetId": spreadsheetID, - "sheetId": sheetID, + "sheetId": target.ID, "index": *c.To, } - if resolvedTitle != "" { - payload["title"] = resolvedTitle + if target.Title != "" { + payload["title"] = target.Title } return outfmt.WriteJSON(ctx, os.Stdout, payload) } - if resolvedTitle != "" { - u.Out().Linef("Moved tab %q (sheetId %d) to index %d in spreadsheet %s", resolvedTitle, sheetID, *c.To, spreadsheetID) + if target.Title != "" { + u.Out().Linef("Moved tab %q (sheetId %d) to index %d in spreadsheet %s", target.Title, target.ID, *c.To, spreadsheetID) } else { - u.Out().Linef("Moved sheetId %d to index %d in spreadsheet %s", sheetID, *c.To, spreadsheetID) + u.Out().Linef("Moved sheetId %d to index %d in spreadsheet %s", target.ID, *c.To, spreadsheetID) } return nil } -// resolveSheetTabID accepts either a tab title or a numeric sheet ID and -// returns (sheetID, title). When the caller passed a numeric ID, title is -// empty unless we happen to find a matching tab while listing IDs. -func resolveSheetTabID(ctx context.Context, svc *sheets.Service, spreadsheetID, tab string) (int64, string, error) { +type sheetTabTarget struct { + ID int64 + Title string + Index int64 + Count int +} + +// resolveSheetTab accepts either a tab title or a numeric sheet ID. +func resolveSheetTab(ctx context.Context, svc *sheets.Service, spreadsheetID, tab string) (sheetTabTarget, error) { + catalog, err := fetchSpreadsheetRangeCatalog(ctx, svc, spreadsheetID) + if err != nil { + return sheetTabTarget{}, err + } + if id, err := strconv.ParseInt(tab, 10, 64); err == nil { - // Numeric: try to enrich with title for nicer output, but accept it - // even if the GET fails or the tab isn't found in the map. - if titles, mapErr := fetchSheetIDMap(ctx, svc, spreadsheetID); mapErr == nil { - for title, sheetID := range titles { - if sheetID == id { - return id, title, nil - } + for _, props := range catalog.Sheets { + if props != nil && props.SheetId == id { + return sheetTabTarget{ID: id, Title: props.Title, Index: props.Index, Count: len(catalog.Sheets)}, nil } } - return id, "", nil + return sheetTabTarget{}, usagef("unknown sheetId %d", id) } - sheetIDs, err := fetchSheetIDMap(ctx, svc, spreadsheetID) - if err != nil { - return 0, "", err - } - sheetID, ok := sheetIDs[tab] - if !ok { - return 0, "", usagef("unknown tab %q", tab) + for _, props := range catalog.Sheets { + if props != nil && props.Title == tab { + return sheetTabTarget{ID: props.SheetId, Title: props.Title, Index: props.Index, Count: len(catalog.Sheets)}, nil + } } - return sheetID, tab, nil + return sheetTabTarget{}, usagef("unknown tab %q", tab) } diff --git a/internal/cmd/sheets_reorder_tab_test.go b/internal/cmd/sheets_reorder_tab_test.go index 8d023e12..d3d26509 100644 --- a/internal/cmd/sheets_reorder_tab_test.go +++ b/internal/cmd/sheets_reorder_tab_test.go @@ -106,6 +106,8 @@ func TestSheetsReorderTabCmd_AcceptsNumericSheetID(t *testing.T) { var captured sheets.BatchUpdateSpreadsheetRequest svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ {"properties": map[string]any{"sheetId": 99, "title": "Only", "index": 0}}, + {"properties": map[string]any{"sheetId": 100, "title": "Next", "index": 1}}, + {"properties": map[string]any{"sheetId": 101, "title": "Last", "index": 2}}, }) defer cleanup() newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } @@ -113,7 +115,7 @@ func TestSheetsReorderTabCmd_AcceptsNumericSheetID(t *testing.T) { flags := &RootFlags{Account: "a@b.com"} ctx := newSheetsCmdContext(t) - if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "99", "--to", "5"}, ctx, flags); err != nil { + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "99", "--to", "2"}, ctx, flags); err != nil { t.Fatalf("reorder-tab: %v", err) } @@ -124,8 +126,40 @@ func TestSheetsReorderTabCmd_AcceptsNumericSheetID(t *testing.T) { if req.Properties.SheetId != 99 { t.Fatalf("expected numeric sheetId passed through, got %d", req.Properties.SheetId) } - if req.Properties.Index != 5 { - t.Fatalf("expected target index 5, got %d", req.Properties.Index) + if req.Properties.Index != 3 { + t.Fatalf("expected rightward move to send API index 3, got %d", req.Properties.Index) + } +} + +func TestSheetsReorderTabCmd_RightwardMoveAdjustsAPIIndex(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 11, "title": "First", "index": 0}}, + {"properties": map[string]any{"sheetId": 22, "title": "Second", "index": 1}}, + {"properties": map[string]any{"sheetId": 33, "title": "Third", "index": 2}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "First", "--to", "1"}, ctx, flags); err != nil { + t.Fatalf("reorder-tab: %v", err) + } + + req := captured.Requests[0].UpdateSheetProperties + if req == nil || req.Properties == nil { + t.Fatalf("expected UpdateSheetProperties, got %#v", captured.Requests[0]) + } + if req.Properties.SheetId != 11 { + t.Fatalf("expected sheetId=11, got %d", req.Properties.SheetId) + } + if req.Properties.Index != 2 { + t.Fatalf("expected rightward move to final index 1 to send API index 2, got %d", req.Properties.Index) } } @@ -211,6 +245,47 @@ func TestSheetsReorderTabCmd_UnknownTabName(t *testing.T) { } } +func TestSheetsReorderTabCmd_UnknownNumericSheetID(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 1, "title": "Sheet1", "index": 0}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "99", "--to", "0"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "unknown sheetId 99") { + t.Fatalf("expected unknown-sheetId error, got %v", err) + } +} + +func TestSheetsReorderTabCmd_IndexOutOfRangeRejected(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 1, "title": "Sheet1", "index": 0}}, + {"properties": map[string]any{"sheetId": 2, "title": "Sheet2", "index": 1}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "Sheet1", "--to", "2"}, ctx, flags) + if err == nil || !strings.Contains(err.Error(), "--to must be between 0 and 1") { + t.Fatalf("expected range validation error, got %v", err) + } +} + func TestSheetsReorderTabCmd_NegativeIndexRejected(t *testing.T) { flags := &RootFlags{Account: "a@b.com"} ctx := newSheetsCmdContext(t) From b1b1799b756cdfcf09bc2c6f147ea7bb87852dd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 22:01:28 +0100 Subject: [PATCH 5/6] fix(sheets): preserve reorder metadata on the wire --- internal/cmd/sheets_range_resolve.go | 2 +- internal/cmd/sheets_reorder_tab.go | 15 ++++--- internal/cmd/sheets_reorder_tab_test.go | 58 +++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/internal/cmd/sheets_range_resolve.go b/internal/cmd/sheets_range_resolve.go index 54b789e8..a3735b15 100644 --- a/internal/cmd/sheets_range_resolve.go +++ b/internal/cmd/sheets_range_resolve.go @@ -19,7 +19,7 @@ type spreadsheetRangeCatalog struct { func fetchSpreadsheetRangeCatalog(ctx context.Context, svc *sheets.Service, spreadsheetID string) (*spreadsheetRangeCatalog, error) { call := svc.Spreadsheets.Get(spreadsheetID). - Fields("sheets(properties(sheetId,title)),namedRanges(namedRangeId,name,range)") + Fields("sheets(properties(sheetId,title,index)),namedRanges(namedRangeId,name,range)") if ctx != nil { call = call.Context(ctx) } diff --git a/internal/cmd/sheets_reorder_tab.go b/internal/cmd/sheets_reorder_tab.go index fd13fac2..23b3cf84 100644 --- a/internal/cmd/sheets_reorder_tab.go +++ b/internal/cmd/sheets_reorder_tab.go @@ -71,16 +71,19 @@ func (c *SheetsReorderTabCmd) Run(ctx context.Context, flags *RootFlags) error { apiIndex++ } + props := &sheets.SheetProperties{ + SheetId: target.ID, + Index: apiIndex, + ForceSendFields: []string{"Index"}, + } + forceSendSheetPropertiesSheetID(props) + req := &sheets.BatchUpdateSpreadsheetRequest{ Requests: []*sheets.Request{ { UpdateSheetProperties: &sheets.UpdateSheetPropertiesRequest{ - Properties: &sheets.SheetProperties{ - SheetId: target.ID, - Index: apiIndex, - ForceSendFields: []string{"Index"}, - }, - Fields: "index", + Properties: props, + Fields: "index", }, }, }, diff --git a/internal/cmd/sheets_reorder_tab_test.go b/internal/cmd/sheets_reorder_tab_test.go index d3d26509..e7167abb 100644 --- a/internal/cmd/sheets_reorder_tab_test.go +++ b/internal/cmd/sheets_reorder_tab_test.go @@ -225,6 +225,64 @@ func TestSheetsReorderTabCmd_IndexZeroIsSerialized(t *testing.T) { } } +func TestSheetsReorderTabCmd_SheetIDZeroIsSerialized(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var rawBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/sheets/v4") + path = strings.TrimPrefix(path, "/v4") + switch { + case strings.HasPrefix(path, "/spreadsheets/s1") && !strings.Contains(path, ":batchUpdate") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "s1", + "sheets": []map[string]any{ + {"properties": map[string]any{"sheetId": 0, "title": "First", "index": 0}}, + {"properties": map[string]any{"sheetId": 22, "title": "Second", "index": 1}}, + }, + }) + case strings.Contains(path, "/spreadsheets/s1:batchUpdate") && r.Method == http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + rawBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + svc, err := sheets.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "First", "--to", "1"}, ctx, flags); err != nil { + t.Fatalf("reorder-tab: %v", err) + } + + body := string(rawBody) + if !strings.Contains(body, `"sheetId":0`) { + t.Fatalf("expected raw body to contain \"sheetId\":0; body = %s", rawBody) + } + if !strings.Contains(body, `"index":2`) { + t.Fatalf("expected rightward final index 1 to send API index 2; body = %s", rawBody) + } +} + func TestSheetsReorderTabCmd_UnknownTabName(t *testing.T) { origNew := newSheetsService t.Cleanup(func() { newSheetsService = origNew }) From 8908692fbca064acf3970198309b4f7da002a5ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 20 May 2026 22:05:55 +0100 Subject: [PATCH 6/6] fix(sheets): allow numeric reorder tab titles --- internal/cmd/sheets_reorder_tab.go | 12 +++++----- internal/cmd/sheets_reorder_tab_test.go | 32 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/internal/cmd/sheets_reorder_tab.go b/internal/cmd/sheets_reorder_tab.go index 23b3cf84..ff35a51c 100644 --- a/internal/cmd/sheets_reorder_tab.go +++ b/internal/cmd/sheets_reorder_tab.go @@ -127,6 +127,12 @@ func resolveSheetTab(ctx context.Context, svc *sheets.Service, spreadsheetID, ta return sheetTabTarget{}, err } + for _, props := range catalog.Sheets { + if props != nil && props.Title == tab { + return sheetTabTarget{ID: props.SheetId, Title: props.Title, Index: props.Index, Count: len(catalog.Sheets)}, nil + } + } + if id, err := strconv.ParseInt(tab, 10, 64); err == nil { for _, props := range catalog.Sheets { if props != nil && props.SheetId == id { @@ -135,11 +141,5 @@ func resolveSheetTab(ctx context.Context, svc *sheets.Service, spreadsheetID, ta } return sheetTabTarget{}, usagef("unknown sheetId %d", id) } - - for _, props := range catalog.Sheets { - if props != nil && props.Title == tab { - return sheetTabTarget{ID: props.SheetId, Title: props.Title, Index: props.Index, Count: len(catalog.Sheets)}, nil - } - } return sheetTabTarget{}, usagef("unknown tab %q", tab) } diff --git a/internal/cmd/sheets_reorder_tab_test.go b/internal/cmd/sheets_reorder_tab_test.go index e7167abb..08cb30d9 100644 --- a/internal/cmd/sheets_reorder_tab_test.go +++ b/internal/cmd/sheets_reorder_tab_test.go @@ -163,6 +163,38 @@ func TestSheetsReorderTabCmd_RightwardMoveAdjustsAPIIndex(t *testing.T) { } } +func TestSheetsReorderTabCmd_PrefersNumericTitleBeforeSheetID(t *testing.T) { + origNew := newSheetsService + t.Cleanup(func() { newSheetsService = origNew }) + + var captured sheets.BatchUpdateSpreadsheetRequest + svc, cleanup := newSheetsTestServer(t, &captured, []map[string]any{ + {"properties": map[string]any{"sheetId": 11, "title": "2024", "index": 0}}, + {"properties": map[string]any{"sheetId": 2024, "title": "Other", "index": 1}}, + {"properties": map[string]any{"sheetId": 33, "title": "Last", "index": 2}}, + }) + defer cleanup() + newSheetsService = func(context.Context, string) (*sheets.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + ctx := newSheetsCmdContext(t) + + if err := runKong(t, &SheetsReorderTabCmd{}, []string{"s1", "--tab", "2024", "--to", "2"}, ctx, flags); err != nil { + t.Fatalf("reorder-tab: %v", err) + } + + req := captured.Requests[0].UpdateSheetProperties + if req == nil || req.Properties == nil { + t.Fatalf("expected UpdateSheetProperties, got %#v", captured.Requests[0]) + } + if req.Properties.SheetId != 11 { + t.Fatalf("expected numeric-looking title to resolve before sheetId, got sheetId %d", req.Properties.SheetId) + } + if req.Properties.Index != 3 { + t.Fatalf("expected rightward move to send API index 3, got %d", req.Properties.Index) + } +} + func TestSheetsReorderTabCmd_IndexZeroIsSerialized(t *testing.T) { // Index=0 is the leftmost position and is also Go's zero value for int64. // Without ForceSendFields the JSON wire format would omit it and the API