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
20 changes: 15 additions & 5 deletions shortcuts/sheets/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
Expand All @@ -97,7 +99,7 @@ func normalizeWriteRange(sheetID, input string, values interface{}) string {
}
Comment thread
caojie0621 marked this conversation as resolved.

func validateSheetRangeInput(sheetID, input string) error {
input = strings.TrimSpace(input)
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID != "" {
return nil
}
Expand All @@ -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
}
Expand All @@ -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 ""
Expand Down
148 changes: 148 additions & 0 deletions shortcuts/sheets/sheet_ranges_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading