Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
194 changes: 194 additions & 0 deletions cmd/sheets.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,45 @@ var sheetsFreezeCmd = &cobra.Command{
RunE: runSheetsFreeze,
}

var sheetsCopyToCmd = &cobra.Command{
Use: "copy-to <spreadsheet-id>",
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 <spreadsheet-id>",
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 <spreadsheet-id>",
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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
})
}
138 changes: 138 additions & 0 deletions cmd/sheets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions cmd/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading