From e747016a0b1ba1afe0d5ddb8355e2ca0ea1342b1 Mon Sep 17 00:00:00 2001 From: / Date: Sat, 18 Apr 2026 10:53:18 +0800 Subject: [PATCH] fix(sheets): normalize single-cell range in +set-style and +batch-set-style /style and /styles_batch_update require full "A1:A1" form and reject single-cell shorthand "A1". +set-style was using normalizeSheetRange (prefix-only) and +batch-set-style passed --data through unchanged, so both failed with `wrong range` when callers supplied a single cell. Switch +set-style to normalizePointRange, and walk each ranges[] entry in +batch-set-style through normalizePointRange before sending. Multi-cell spans pass through unchanged. --- shortcuts/sheets/sheet_batch_set_style.go | 35 ++++- shortcuts/sheets/sheet_cell_ops_test.go | 183 ++++++++++++++++++++++ shortcuts/sheets/sheet_set_style.go | 4 +- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/shortcuts/sheets/sheet_batch_set_style.go b/shortcuts/sheets/sheet_batch_set_style.go index c9da372d2..7d3e502cb 100644 --- a/shortcuts/sheets/sheet_batch_set_style.go +++ b/shortcuts/sheets/sheet_batch_set_style.go @@ -22,7 +22,7 @@ var SheetBatchSetStyle = common.Shortcut{ Flags: []common.Flag{ {Name: "url", Desc: "spreadsheet URL"}, {Name: "spreadsheet-token", Desc: "spreadsheet token"}, - {Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true}, + {Name: "data", Desc: "JSON array of {ranges, style} objects; each range must carry a sheetId! prefix (e.g. sheet1!A1)", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { token := runtime.Str("spreadsheet-token") @@ -49,6 +49,7 @@ var SheetBatchSetStyle = common.Shortcut{ } var data interface{} json.Unmarshal([]byte(runtime.Str("data")), &data) + normalizeBatchStyleRanges(data) return common.NewDryRunAPI(). PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update"). Body(map[string]interface{}{ @@ -66,6 +67,7 @@ var SheetBatchSetStyle = common.Shortcut{ if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil { return common.FlagErrorf("--data must be valid JSON: %v", err) } + normalizeBatchStyleRanges(data) result, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)), @@ -81,3 +83,34 @@ var SheetBatchSetStyle = common.Shortcut{ return nil }, } + +// normalizeBatchStyleRanges mutates each string entry in data[].ranges in place +// so the /styles_batch_update endpoint accepts single-cell shorthand. +// Entries carrying a sheetId! prefix (e.g. "sheet1!A1") are expanded to +// "sheet1!A1:A1"; multi-cell spans pass through unchanged. +// A bare single cell without the sheetId! prefix (e.g. "A1") cannot be +// expanded because the helper has no sheet-id context (the shortcut exposes +// no --sheet-id flag), and the backend would reject the payload anyway — +// such entries pass through unchanged. Non-string entries, missing +// ranges keys, and non-array top-level inputs are ignored silently. +func normalizeBatchStyleRanges(data interface{}) { + items, ok := data.([]interface{}) + if !ok { + return + } + for _, item := range items { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + ranges, ok := entry["ranges"].([]interface{}) + if !ok { + continue + } + for i, r := range ranges { + if s, ok := r.(string); ok { + ranges[i] = normalizePointRange("", s) + } + } + } +} diff --git a/shortcuts/sheets/sheet_cell_ops_test.go b/shortcuts/sheets/sheet_cell_ops_test.go index bf4af00eb..a1316bf86 100644 --- a/shortcuts/sheets/sheet_cell_ops_test.go +++ b/shortcuts/sheets/sheet_cell_ops_test.go @@ -414,6 +414,46 @@ func TestSheetSetStyleExecuteSuccess(t *testing.T) { } } +func TestSheetSetStyleDryRunExpandsSingleCell(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", "range": "A1", "sheet-id": "sheet1", + "style": `{"font":{"bold":true}}`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"range":"sheet1!A1:A1"`) { + t.Fatalf("DryRun should expand single cell to A1:A1: %s", got) + } +} + +func TestSheetSetStyleExecuteExpandsSingleCell(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "updates": map[string]interface{}{"updatedCells": float64(1), "updatedRange": "sheet1!A1:A1"}, + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetSetStyle, []string{ + "+set-style", "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", "--range", "A1", + "--style", `{"font":{"bold":true}}`, "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + appendStyle, _ := body["appendStyle"].(map[string]interface{}) + if appendStyle["range"] != "sheet1!A1:A1" { + t.Fatalf("single cell should be expanded to sheet1!A1:A1, got: %v", appendStyle["range"]) + } +} + func TestSheetSetStyleExecuteAPIError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) reg.Register(&httpmock.Stub{ @@ -523,6 +563,51 @@ func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) { } } +func TestSheetBatchSetStyleDryRunExpandsSingleCells(t *testing.T) { + t.Parallel() + rt := newSheetsTestRuntime(t, map[string]string{ + "url": "", "spreadsheet-token": "sht_test", + "data": `[{"ranges":["sheet1!A2","sheet1!B2"],"style":{"font":{"bold":true}}}]`, + }, nil) + got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt)) + if !strings.Contains(got, `"sheet1!A2:A2"`) || !strings.Contains(got, `"sheet1!B2:B2"`) { + t.Fatalf("DryRun should expand single cells to A2:A2 and B2:B2: %s", got) + } +} + +func TestSheetBatchSetStyleExecuteNormalizesMixedRanges(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{ + "totalUpdatedCells": float64(5), + }}, + } + reg.Register(stub) + err := mountAndRunSheets(t, SheetBatchSetStyle, []string{ + "+batch-set-style", "--spreadsheet-token", "shtTOKEN", + "--data", `[{"ranges":["sheet1!C1:D2","sheet1!E3"],"style":{"font":{"italic":true}}}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse body: %v", err) + } + data, _ := body["data"].([]interface{}) + if len(data) != 1 { + t.Fatalf("expected 1 data entry, got %d", len(data)) + } + entry, _ := data[0].(map[string]interface{}) + ranges, _ := entry["ranges"].([]interface{}) + if len(ranges) != 2 || ranges[0] != "sheet1!C1:D2" || ranges[1] != "sheet1!E3:E3" { + t.Fatalf("ranges should preserve span and expand single cell, got: %v", ranges) + } +} + func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) reg.Register(&httpmock.Stub{ @@ -537,3 +622,101 @@ func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) { t.Fatal("expected error") } } + +func TestNormalizeBatchStyleRanges(t *testing.T) { + t.Parallel() + + t.Run("single cell with sheet prefix is expanded in place", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"sheet1!A1", "sheet1!B2"}, + "style": map[string]interface{}{"font": map[string]interface{}{"bold": true}}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "sheet1!A1:A1" || got[1] != "sheet1!B2:B2" { + t.Fatalf("want [sheet1!A1:A1 sheet1!B2:B2], got %v", got) + } + }) + + t.Run("multi-cell span passes through unchanged", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"sheet1!A1:B2"}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "sheet1!A1:B2" { + t.Fatalf("multi-cell span should be unchanged, got %v", got[0]) + } + }) + + t.Run("bare single cell without sheet prefix passes through", func(t *testing.T) { + t.Parallel() + // Without a sheetId! prefix there's no sheet context; entry is left + // alone and the backend will reject it. Documented in the helper. + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"A1"}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "A1" { + t.Fatalf("bare single cell should pass through, got %v", got[0]) + } + }) + + t.Run("non-string entries are preserved", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "ranges": []interface{}{"sheet1!A1", 42, nil, "sheet1!B2"}, + }, + } + normalizeBatchStyleRanges(data) + got := data[0].(map[string]interface{})["ranges"].([]interface{}) + if got[0] != "sheet1!A1:A1" { + t.Fatalf("first entry should be expanded, got %v", got[0]) + } + if got[1] != 42 { + t.Fatalf("int entry should be preserved, got %v", got[1]) + } + if got[2] != nil { + t.Fatalf("nil entry should be preserved, got %v", got[2]) + } + if got[3] != "sheet1!B2:B2" { + t.Fatalf("last entry should be expanded, got %v", got[3]) + } + }) + + t.Run("missing or non-array ranges key is skipped", func(t *testing.T) { + t.Parallel() + data := []interface{}{ + map[string]interface{}{ + "style": map[string]interface{}{"font": map[string]interface{}{"bold": true}}, + }, + map[string]interface{}{ + "ranges": "not-an-array", + }, + "not-a-map", + } + normalizeBatchStyleRanges(data) + if data[1].(map[string]interface{})["ranges"] != "not-an-array" { + t.Fatal("non-array ranges should be left alone") + } + }) + + t.Run("top-level non-array inputs do not panic", func(t *testing.T) { + t.Parallel() + // Any of these would panic if the helper didn't guard its type assertions. + normalizeBatchStyleRanges(nil) + normalizeBatchStyleRanges(map[string]interface{}{"foo": "bar"}) + normalizeBatchStyleRanges("string") + normalizeBatchStyleRanges(42) + }) +} diff --git a/shortcuts/sheets/sheet_set_style.go b/shortcuts/sheets/sheet_set_style.go index 6da9976e8..15d953adb 100644 --- a/shortcuts/sheets/sheet_set_style.go +++ b/shortcuts/sheets/sheet_set_style.go @@ -51,7 +51,7 @@ var SheetSetStyle = common.Shortcut{ if runtime.Str("url") != "" { token = extractSpreadsheetToken(runtime.Str("url")) } - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) var style interface{} json.Unmarshal([]byte(runtime.Str("style")), &style) return common.NewDryRunAPI(). @@ -70,7 +70,7 @@ var SheetSetStyle = common.Shortcut{ token = extractSpreadsheetToken(runtime.Str("url")) } - r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range")) + r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range")) var style interface{} if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil { return common.FlagErrorf("--style must be valid JSON: %v", err)