diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 8604cbeeb..ba1ca6420 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -23,6 +23,8 @@ var ( cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`) ) +var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!") + // getFirstSheetID queries the spreadsheet and returns the first sheet's ID. func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) { data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil) @@ -56,7 +58,7 @@ func extractSpreadsheetToken(input string) string { } func normalizeSheetRange(sheetID, input string) string { - input = strings.TrimSpace(input) + input = normalizeSheetRangeSeparators(input) if input == "" || strings.Contains(input, "!") || sheetID == "" { return input } @@ -80,7 +82,7 @@ func normalizePointRange(sheetID, input string) string { func normalizeWriteRange(sheetID, input string, values interface{}) string { rows, cols := matrixDimensions(values) - input = strings.TrimSpace(input) + input = normalizeSheetRangeSeparators(input) if input == "" { return buildRectRange(sheetID, "A1", rows, cols) } @@ -97,7 +99,7 @@ func normalizeWriteRange(sheetID, input string, values interface{}) string { } func validateSheetRangeInput(sheetID, input string) error { - input = strings.TrimSpace(input) + input = normalizeSheetRangeSeparators(input) if input == "" || strings.Contains(input, "!") || sheetID != "" { return nil } @@ -108,7 +110,7 @@ func validateSheetRangeInput(sheetID, input string) error { } func looksLikeRelativeRange(input string) bool { - input = strings.TrimSpace(input) + input = normalizeSheetRangeSeparators(input) if input == "" { return false } @@ -120,13 +122,21 @@ func looksLikeRelativeRange(input string) bool { } func splitSheetRange(input string) (sheetID, subRange string, ok bool) { - parts := strings.SplitN(strings.TrimSpace(input), "!", 2) + parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", false } return parts[0], parts[1], true } +func normalizeSheetRangeSeparators(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return input + } + return sheetRangeSeparatorReplacer.Replace(input) +} + func buildRectRange(sheetID, anchor string, rows, cols int) string { if sheetID == "" { return "" diff --git a/shortcuts/sheets/sheet_ranges_test.go b/shortcuts/sheets/sheet_ranges_test.go new file mode 100644 index 000000000..b5eb2b6e4 --- /dev/null +++ b/shortcuts/sheets/sheet_ranges_test.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func mustMarshalSheetsDryRun(t *testing.T, v interface{}) string { + t.Helper() + + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return string(b) +} + +func newSheetsTestRuntime(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + for name := range stringFlags { + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, value := range stringFlags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, value := range boolFlags { + if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[value]); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestNormalizeSheetRangeSeparators(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "standard", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"}, + {name: "escaped ascii", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"}, + {name: "fullwidth", input: "sheet_123!A1:B2", want: "sheet_123!A1:B2"}, + {name: "escaped fullwidth", input: `sheet_123\!A1:B2`, want: "sheet_123!A1:B2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := normalizeSheetRangeSeparators(tt.input); got != tt.want { + t.Fatalf("normalizeSheetRangeSeparators(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestValidateSheetRangeInputAcceptsEscapedSeparator(t *testing.T) { + t.Parallel() + + if err := validateSheetRangeInput("", `sheet_123\!A1:B2`); err != nil { + t.Fatalf("validateSheetRangeInput() error = %v, want nil", err) + } +} + +func TestSheetReadDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": `sheet_123\!A1`, + "sheet-id": "", + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:A1"`) { + t.Fatalf("SheetRead.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetWriteDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": `sheet_123\!A1:B2`, + "values": `[[1,2],[3,4]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { + t.Fatalf("SheetWrite.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "range": `sheet_123\!A1:B2`, + "values": `[["foo","bar"]]`, + }, nil) + + got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { + t.Fatalf("SheetAppend.DryRun() = %s, want normalized escaped separator", got) + } +} + +func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) { + t.Parallel() + + runtime := newSheetsTestRuntime(t, map[string]string{ + "spreadsheet-token": "sht_test", + "sheet-id": "sheet_123", + "find": "target", + "range": `sheet_123\!A1:B2`, + }, map[string]bool{ + "ignore-case": false, + "match-entire-cell": false, + "search-by-regex": false, + "include-formulas": false, + }) + + got := mustMarshalSheetsDryRun(t, SheetFind.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"range":"sheet_123!A1:B2"`) { + t.Fatalf("SheetFind.DryRun() = %s, want normalized escaped separator", got) + } +}