diff --git a/cmd/commands_test.go b/cmd/commands_test.go index 33af825..65c7db7 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -398,6 +398,9 @@ func TestSheetsCommands(t *testing.T) { {"set-column-width"}, {"set-row-height"}, {"freeze"}, + {"copy-to"}, + {"batch-read"}, + {"batch-write"}, } for _, tt := range tests { diff --git a/cmd/sheets.go b/cmd/sheets.go index de1ed63..ff46353 100644 --- a/cmd/sheets.go +++ b/cmd/sheets.go @@ -256,6 +256,45 @@ var sheetsFreezeCmd = &cobra.Command{ RunE: runSheetsFreeze, } +var sheetsCopyToCmd = &cobra.Command{ + Use: "copy-to ", + Short: "Copy a sheet to another spreadsheet", + Long: "Copies a sheet tab from one spreadsheet to another spreadsheet.", + Args: cobra.ExactArgs(1), + RunE: runSheetsCopyTo, +} + +var sheetsBatchReadCmd = &cobra.Command{ + Use: "batch-read ", + Short: "Read multiple ranges", + Long: `Reads multiple ranges from a spreadsheet in a single API call. + +Range format examples: + --ranges "Sheet1!A1:D10" - Specific range in Sheet1 + --ranges "A1:B5" - Range in first sheet + --ranges "Sheet2!A1:C10" - Range in Sheet2 + +Multiple ranges can be specified by repeating the --ranges flag.`, + Args: cobra.ExactArgs(1), + RunE: runSheetsBatchRead, +} + +var sheetsBatchWriteCmd = &cobra.Command{ + Use: "batch-write ", + Short: "Write to multiple ranges", + Long: `Writes values to multiple ranges in a spreadsheet in a single API call. + +Each range-values pair is specified with --ranges and --values flags. +The nth --ranges corresponds to the nth --values. + +Example: + gws sheets batch-write SPREADSHEET_ID \ + --ranges "A1:B2" --values '[[1,2],[3,4]]' \ + --ranges "Sheet2!A1:B1" --values '[["x","y"]]'`, + Args: cobra.ExactArgs(1), + RunE: runSheetsBatchWrite, +} + func init() { rootCmd.AddCommand(sheetsCmd) sheetsCmd.AddCommand(sheetsInfoCmd) @@ -388,6 +427,27 @@ func init() { sheetsFindReplaceCmd.Flags().Bool("entire-cell", false, "Match entire cell contents only") sheetsFindReplaceCmd.MarkFlagRequired("find") sheetsFindReplaceCmd.MarkFlagRequired("replace") + + // Copy-to command + sheetsCmd.AddCommand(sheetsCopyToCmd) + sheetsCopyToCmd.Flags().Int64("sheet-id", 0, "Source sheet ID to copy (required)") + sheetsCopyToCmd.Flags().String("destination", "", "Destination spreadsheet ID (required)") + sheetsCopyToCmd.MarkFlagRequired("sheet-id") + sheetsCopyToCmd.MarkFlagRequired("destination") + + // Batch-read command + sheetsCmd.AddCommand(sheetsBatchReadCmd) + sheetsBatchReadCmd.Flags().StringSlice("ranges", nil, "Ranges to read (can be repeated)") + sheetsBatchReadCmd.Flags().String("value-render", "FORMATTED_VALUE", "Value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, FORMULA") + sheetsBatchReadCmd.MarkFlagRequired("ranges") + + // Batch-write command + sheetsCmd.AddCommand(sheetsBatchWriteCmd) + sheetsBatchWriteCmd.Flags().StringSlice("ranges", nil, "Target ranges (can be repeated, pairs with --values)") + sheetsBatchWriteCmd.Flags().StringSlice("values", nil, "JSON arrays of values (can be repeated, pairs with --ranges)") + sheetsBatchWriteCmd.Flags().String("value-input", "USER_ENTERED", "Value input option: RAW, USER_ENTERED") + sheetsBatchWriteCmd.MarkFlagRequired("ranges") + sheetsBatchWriteCmd.MarkFlagRequired("values") } func runSheetsInfo(cmd *cobra.Command, args []string) error { @@ -1927,3 +1987,137 @@ func runSheetsFreeze(cmd *cobra.Command, args []string) error { "frozen_cols": freezeCols, }) } + +func runSheetsCopyTo(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + sheetID, _ := cmd.Flags().GetInt64("sheet-id") + destination, _ := cmd.Flags().GetString("destination") + + req := &sheets.CopySheetToAnotherSpreadsheetRequest{ + DestinationSpreadsheetId: destination, + } + + resp, err := svc.Spreadsheets.Sheets.CopyTo(spreadsheetID, sheetID, req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to copy sheet: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "copied", + "source_spreadsheet": spreadsheetID, + "source_sheet_id": sheetID, + "destination": destination, + "new_sheet_id": resp.SheetId, + "new_sheet_title": resp.Title, + "new_sheet_index": resp.Index, + }) +} + +func runSheetsBatchRead(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + ranges, _ := cmd.Flags().GetStringSlice("ranges") + valueRender, _ := cmd.Flags().GetString("value-render") + + resp, err := svc.Spreadsheets.Values.BatchGet(spreadsheetID). + Ranges(ranges...). + ValueRenderOption(valueRender). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch read: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.ValueRanges)) + for _, vr := range resp.ValueRanges { + results = append(results, map[string]interface{}{ + "range": vr.Range, + "data": vr.Values, + "rows": len(vr.Values), + }) + } + + return p.Print(map[string]interface{}{ + "spreadsheet": resp.SpreadsheetId, + "ranges": results, + "range_count": len(results), + }) +} + +func runSheetsBatchWrite(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.Sheets() + if err != nil { + return p.PrintError(err) + } + + spreadsheetID := args[0] + ranges, _ := cmd.Flags().GetStringSlice("ranges") + valuesStrs, _ := cmd.Flags().GetStringSlice("values") + valueInput, _ := cmd.Flags().GetString("value-input") + + if len(ranges) != len(valuesStrs) { + return p.PrintError(fmt.Errorf("number of --ranges flags (%d) must match number of --values flags (%d)", len(ranges), len(valuesStrs))) + } + + data := make([]*sheets.ValueRange, 0, len(ranges)) + for i, rangeStr := range ranges { + var rawValues [][]interface{} + if err := json.Unmarshal([]byte(valuesStrs[i]), &rawValues); err != nil { + return p.PrintError(fmt.Errorf("invalid JSON for values[%d]: %w", i, err)) + } + data = append(data, &sheets.ValueRange{ + Range: rangeStr, + Values: rawValues, + }) + } + + req := &sheets.BatchUpdateValuesRequest{ + ValueInputOption: valueInput, + Data: data, + } + + resp, err := svc.Spreadsheets.Values.BatchUpdate(spreadsheetID, req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch write: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "written", + "spreadsheet": resp.SpreadsheetId, + "sheets_updated": resp.TotalUpdatedSheets, + "rows_updated": resp.TotalUpdatedRows, + "cells_updated": resp.TotalUpdatedCells, + }) +} diff --git a/cmd/sheets_test.go b/cmd/sheets_test.go index d4b491a..4523108 100644 --- a/cmd/sheets_test.go +++ b/cmd/sheets_test.go @@ -1411,6 +1411,144 @@ func TestSheetsSetRowHeight_MockServer(t *testing.T) { } } +// TestSheetsCopyToCommand_Flags tests copy-to command flags +func TestSheetsCopyToCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "copy-to") + if cmd == nil { + t.Fatal("copy-to command not found") + } + + expectedFlags := []string{"sheet-id", "destination"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsBatchReadCommand_Flags tests batch-read command flags +func TestSheetsBatchReadCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "batch-read") + if cmd == nil { + t.Fatal("batch-read command not found") + } + + expectedFlags := []string{"ranges", "value-render"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsBatchWriteCommand_Flags tests batch-write command flags +func TestSheetsBatchWriteCommand_Flags(t *testing.T) { + cmd := findSubcommand(sheetsCmd, "batch-write") + if cmd == nil { + t.Fatal("batch-write command not found") + } + + expectedFlags := []string{"ranges", "values", "value-input"} + for _, flag := range expectedFlags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected flag '--%s' not found", flag) + } + } +} + +// TestSheetsCopyTo_MockServer tests copy-to API integration +func TestSheetsCopyTo_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.Contains(r.URL.Path, "/sheets/") && strings.Contains(r.URL.Path, ":copyTo") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + + destID, _ := req["destinationSpreadsheetId"].(string) + if destID == "" { + t.Error("expected destinationSpreadsheetId in request") + } + + resp := map[string]interface{}{ + "sheetId": 99, + "title": "Sheet1 (copy)", + "index": 1, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsBatchRead_MockServer tests batch-read API integration +func TestSheetsBatchRead_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/values:batchGet") { + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "valueRanges": []map[string]interface{}{ + { + "range": "Sheet1!A1:B2", + "values": [][]interface{}{{"a", "b"}, {"c", "d"}}, + }, + { + "range": "Sheet2!A1:C1", + "values": [][]interface{}{{"x", "y", "z"}}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + +// TestSheetsBatchWrite_MockServer tests batch-write API integration +func TestSheetsBatchWrite_MockServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && strings.Contains(r.URL.Path, "/values:batchUpdate") { + body, _ := io.ReadAll(r.Body) + var req map[string]interface{} + json.Unmarshal(body, &req) + + data, ok := req["data"].([]interface{}) + if !ok || len(data) == 0 { + t.Error("expected data in batch write request") + } + + resp := map[string]interface{}{ + "spreadsheetId": "test-id", + "totalUpdatedSheets": 2, + "totalUpdatedRows": 3, + "totalUpdatedCells": 6, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + if server == nil { + t.Fatal("server not created") + } +} + // TestSheetsFreeze_MockServer tests freeze panes API integration func TestSheetsFreeze_MockServer(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/skills_test.go b/cmd/skills_test.go index 6bc058e..e846916 100644 --- a/cmd/skills_test.go +++ b/cmd/skills_test.go @@ -368,6 +368,7 @@ func TestSkillCommands_MatchCLI(t *testing.T) { "insert-rows", "delete-rows", "insert-cols", "delete-cols", "rename-sheet", "duplicate-sheet", "merge", "unmerge", "sort", "find-replace", + "copy-to", "batch-read", "batch-write", }, }, "slides": { diff --git a/skills/sheets/SKILL.md b/skills/sheets/SKILL.md index a07bdd6..aee6cb5 100644 --- a/skills/sheets/SKILL.md +++ b/skills/sheets/SKILL.md @@ -9,7 +9,7 @@ metadata: # Google Sheets (gws sheets) -`gws sheets` provides CLI access to Google Sheets with structured JSON output. This is the largest skill with 19 commands covering full spreadsheet management. +`gws sheets` provides CLI access to Google Sheets with structured JSON output. This skill has 22 commands covering full spreadsheet management including batch operations. > **Disclaimer:** `gws` is not the official Google CLI. This is an independent, open-source project not endorsed by or affiliated with Google. @@ -48,6 +48,13 @@ For initial setup, see the `gws-auth` skill. | Append rows | `gws sheets append "Sheet1" --values "x,y,z"` | | Clear cells | `gws sheets clear "Sheet1!A1:D10"` | +### Batch & Cross-Spreadsheet Operations +| Task | Command | +|------|---------| +| Read multiple ranges | `gws sheets batch-read --ranges "A1:B5" --ranges "Sheet2!A1:C10"` | +| Write multiple ranges | `gws sheets batch-write --ranges "A1:B2" --values '[[1,2],[3,4]]'` | +| Copy sheet to another | `gws sheets copy-to --sheet-id 0 --destination ` | + ### Sheet Management | Task | Command | |------|---------| @@ -245,6 +252,39 @@ gws sheets freeze --sheet --rows --cols - `--rows int` — Number of rows to freeze - `--cols int` — Number of columns to freeze +### copy-to — Copy a sheet to another spreadsheet + +```bash +gws sheets copy-to --sheet-id --destination +``` + +**Flags:** +- `--sheet-id int` — Source sheet ID to copy (required) +- `--destination string` — Destination spreadsheet ID (required) + +### batch-read — Read multiple ranges + +```bash +gws sheets batch-read --ranges "A1:B5" --ranges "Sheet2!A1:C10" [flags] +``` + +**Flags:** +- `--ranges strings` — Ranges to read (can be repeated, required) +- `--value-render string` — Value render option: `FORMATTED_VALUE`, `UNFORMATTED_VALUE`, `FORMULA` (default: "FORMATTED_VALUE") + +### batch-write — Write to multiple ranges + +```bash +gws sheets batch-write --ranges "A1:B2" --values '[[1,2],[3,4]]' [flags] +``` + +**Flags:** +- `--ranges strings` — Target ranges (can be repeated, pairs with `--values`, required) +- `--values strings` — JSON arrays of values (can be repeated, pairs with `--ranges`, required) +- `--value-input string` — Value input option: `RAW`, `USER_ENTERED` (default: "USER_ENTERED") + +The nth `--ranges` pairs with the nth `--values`. + ## Output Modes ```bash diff --git a/skills/sheets/references/commands.md b/skills/sheets/references/commands.md index 5601a68..befbf18 100644 --- a/skills/sheets/references/commands.md +++ b/skills/sheets/references/commands.md @@ -1,6 +1,6 @@ # Sheets Commands Reference -Complete flag and option reference for `gws sheets` commands — 27 commands total. +Complete flag and option reference for `gws sheets` commands — 30 commands total. > **Disclaimer:** `gws` is not the official Google CLI. This is an independent, open-source project not endorsed by or affiliated with Google. @@ -476,3 +476,114 @@ gws sheets freeze 1abc123xyz --sheet "Sheet1" --rows 0 --cols 0 - Frozen columns remain visible when scrolling horizontally - Common pattern: freeze 1 row (header) and/or 1 column (labels) - To unfreeze completely, set both `--rows 0 --cols 0` + +--- + +## gws sheets copy-to + +Copies a sheet tab from one spreadsheet to another. + +``` +Usage: gws sheets copy-to [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--sheet-id` | int | 0 | Yes | Source sheet ID to copy | +| `--destination` | string | | Yes | Destination spreadsheet ID | + +### Examples + +```bash +# Copy sheet 0 to another spreadsheet +gws sheets copy-to 1abc123xyz --sheet-id 0 --destination 2def456uvw + +# Copy sheet by ID (get IDs from gws sheets list) +gws sheets copy-to 1abc123xyz --sheet-id 12345 --destination 2def456uvw +``` + +### Notes + +- Use `gws sheets list ` to find sheet IDs +- The copied sheet appears as a new tab in the destination spreadsheet +- The copy inherits all data, formatting, and conditional formatting + +--- + +## gws sheets batch-read + +Reads multiple ranges from a spreadsheet in a single API call. + +``` +Usage: gws sheets batch-read [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--ranges` | strings | | Yes | Ranges to read (can be repeated) | +| `--value-render` | string | `FORMATTED_VALUE` | No | Value render option | + +**Value render options:** +- `FORMATTED_VALUE` — Values as displayed in the UI (default) +- `UNFORMATTED_VALUE` — Raw unformatted values +- `FORMULA` — Formulas instead of computed values + +### Examples + +```bash +# Read two ranges +gws sheets batch-read 1abc123xyz --ranges "Sheet1!A1:B5" --ranges "Sheet2!A1:C10" + +# Read with formulas visible +gws sheets batch-read 1abc123xyz --ranges "A1:D10" --ranges "E1:F10" --value-render FORMULA + +# Read from multiple sheets +gws sheets batch-read 1abc123xyz --ranges "Sales!A1:D100" --ranges "Inventory!A1:C50" --ranges "Summary!A1:B10" +``` + +### Notes + +- More efficient than multiple `gws sheets read` calls +- Each range in the response includes its own data array +- Ranges can span different sheets within the same spreadsheet + +--- + +## gws sheets batch-write + +Writes values to multiple ranges in a single API call. + +``` +Usage: gws sheets batch-write [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--ranges` | strings | | Yes | Target ranges (pairs with `--values`) | +| `--values` | strings | | Yes | JSON arrays of values (pairs with `--ranges`) | +| `--value-input` | string | `USER_ENTERED` | No | Value input option | + +**Value input options:** +- `USER_ENTERED` — Values parsed as if typed by a user (default) +- `RAW` — Values stored exactly as provided + +### Examples + +```bash +# Write to two ranges +gws sheets batch-write 1abc123xyz \ + --ranges "A1:B2" --values '[[1,2],[3,4]]' \ + --ranges "Sheet2!A1:B1" --values '[["x","y"]]' + +# Write raw values (no formula parsing) +gws sheets batch-write 1abc123xyz \ + --ranges "A1:C1" --values '[["=SUM(B1:B10)","hello",42]]' \ + --value-input RAW +``` + +### Notes + +- The nth `--ranges` flag pairs with the nth `--values` flag +- Number of `--ranges` flags must match number of `--values` flags +- Values must be JSON arrays (e.g., `'[["a","b"],["c","d"]]'`) +- More efficient than multiple `gws sheets write` calls