diff --git a/shortcuts/sheets/sheet_add_dimension.go b/shortcuts/sheets/sheet_add_dimension.go new file mode 100644 index 00000000..1a876e85 --- /dev/null +++ b/shortcuts/sheets/sheet_add_dimension.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetAddDimension = common.Shortcut{ + Service: "sheets", + Command: "+add-dimension", + Description: "Add rows or columns at the end of a sheet", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + length := runtime.Int("length") + if length < 1 || length > 5000 { + return common.FlagErrorf("--length must be between 1 and 5000, got %d", length) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "length": runtime.Int("length"), + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "length": runtime.Int("length"), + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_delete_dimension.go b/shortcuts/sheets/sheet_delete_dimension.go new file mode 100644 index 00000000..27ac400f --- /dev/null +++ b/shortcuts/sheets/sheet_delete_dimension.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetDeleteDimension = common.Shortcut{ + Service: "sheets", + Command: "+delete-dimension", + Description: "Delete rows or columns", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if runtime.Int("start-index") < 1 { + return common.FlagErrorf("--start-index must be >= 1") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + return common.NewDryRunAPI(). + DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + data, err := runtime.CallAPI("DELETE", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_dimension_test.go b/shortcuts/sheets/sheet_dimension_test.go new file mode 100644 index 00000000..bd5d6afe --- /dev/null +++ b/shortcuts/sheets/sheet_dimension_test.go @@ -0,0 +1,923 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags. +func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + for name := range strFlags { + cmd.Flags().String(name, "", "") + } + for name := range intFlags { + cmd.Flags().Int(name, 0, "") + } + 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 strFlags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, value := range intFlags { + if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, value := range boolFlags { + if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func marshalDryRun(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) +} + +// ── AddDimension ───────────────────────────────────────────────────────────── + +func TestSheetAddDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"length": 10}, nil) + err := SheetAddDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) { + t.Parallel() + for _, length := range []int{0, -1, 5001} { + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"length": length}, nil) + err := SheetAddDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--length") { + t.Fatalf("length=%d: expected length error, got: %v", length, err) + } + } +} + +func TestSheetAddDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"length": 100}, nil) + if err := SheetAddDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetAddDimensionValidateWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"length": 5}, nil) + if err := SheetAddDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetAddDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"length": 8}, nil) + got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `dimension_range`) { + t.Fatalf("DryRun URL missing dimension_range: %s", got) + } + if !strings.Contains(got, `"sheetId":"sheet1"`) { + t.Fatalf("DryRun missing sheetId: %s", got) + } + if !strings.Contains(got, `"majorDimension":"ROWS"`) { + t.Fatalf("DryRun missing majorDimension: %s", got) + } + if !strings.Contains(got, `"length":8`) { + t.Fatalf("DryRun missing length: %s", got) + } +} + +func TestSheetAddDimensionDryRunWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"length": 3}, nil) + got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt)) + if !strings.Contains(got, "shtFromURL") { + t.Fatalf("DryRun should extract token from URL: %s", got) + } +} + +func TestSheetAddDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Body: map[string]interface{}{ + "code": 0, "msg": "Success", + "data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"}, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetAddDimension, []string{ + "+add-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--length", "8", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"addCount"`) { + t.Fatalf("stdout missing addCount: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + dim, _ := body["dimension"].(map[string]interface{}) + if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { + t.Fatalf("unexpected request body: %#v", body) + } +} + +func TestSheetAddDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetAddDimension, []string{ + "+add-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--length", "8", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── InsertDimension ────────────────────────────────────────────────────────── + +func TestSheetInsertDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, + map[string]int{"start-index": 0, "end-index": 3}, nil) + err := SheetInsertDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, + map[string]int{"start-index": -1, "end-index": 3}, nil) + err := SheetInsertDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""}, + map[string]int{"start-index": 5, "end-index": 5}, nil) + err := SheetInsertDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetInsertDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""}, + map[string]int{"start-index": 0, "end-index": 4}, nil) + if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetInsertDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"}, + map[string]int{"start-index": 3, "end-index": 7}, nil) + got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `insert_dimension_range`) { + t.Fatalf("DryRun URL missing insert_dimension_range: %s", got) + } + if !strings.Contains(got, `"startIndex":3`) { + t.Fatalf("DryRun missing startIndex: %s", got) + } + if !strings.Contains(got, `"endIndex":7`) { + t.Fatalf("DryRun missing endIndex: %s", got) + } + if !strings.Contains(got, `"inheritStyle":"BEFORE"`) { + t.Fatalf("DryRun missing inheritStyle: %s", got) + } +} + +func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""}, + map[string]int{"start-index": 0, "end-index": 2}, nil) + got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt)) + + if strings.Contains(got, `inheritStyle`) { + t.Fatalf("DryRun should omit inheritStyle when empty: %s", got) + } +} + +func TestSheetInsertDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetInsertDimension, []string{ + "+insert-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "3", + "--end-index", "7", + "--inherit-style", "AFTER", + "--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 request body: %v", err) + } + dim, _ := body["dimension"].(map[string]interface{}) + if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { + t.Fatalf("unexpected dimension: %#v", dim) + } + if body["inheritStyle"] != "AFTER" { + t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"]) + } +} + +func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetInsertDimension, []string{ + "+insert-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "COLUMNS", + "--start-index", "0", + "--end-index", "2", + "--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 request body: %v", err) + } + if _, ok := body["inheritStyle"]; ok { + t.Fatalf("inheritStyle should be absent when not specified: %#v", body) + } +} + +func TestSheetInsertDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetInsertDimension, []string{ + "+insert-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "0", + "--end-index", "3", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── UpdateDimension ────────────────────────────────────────────────────────── + +func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) { + t.Parallel() + // Neither --visible nor --fixed-size is set (Changed returns false) + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, nil) + // Register the flags but don't set them so Changed() returns false + rt.Cmd.Flags().Bool("visible", false, "") + rt.Cmd.Flags().Int("fixed-size", 0, "") + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") { + t.Fatalf("expected properties error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, + map[string]bool{"visible": true}) + // Ensure fixed-size flag exists but is not set + rt.Cmd.Flags().Int("fixed-size", 0, "") + if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil) + rt.Cmd.Flags().Bool("visible", false, "") + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") { + t.Fatalf("expected fixed-size error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil) + rt.Cmd.Flags().Bool("visible", false, "") + err := SheetUpdateDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") { + t.Fatalf("expected fixed-size error, got: %v", err) + } +} + +func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil) + // Ensure visible flag exists but is not set + rt.Cmd.Flags().Bool("visible", false, "") + if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetUpdateDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50}, + map[string]bool{"visible": true}) + got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `"method":"PUT"`) { + t.Fatalf("DryRun should use PUT: %s", got) + } + if !strings.Contains(got, `dimension_range`) { + t.Fatalf("DryRun URL missing dimension_range: %s", got) + } + if !strings.Contains(got, `"visible":true`) { + t.Fatalf("DryRun missing visible: %s", got) + } + if !strings.Contains(got, `"fixedSize":50`) { + t.Fatalf("DryRun missing fixedSize: %s", got) + } +} + +func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, + map[string]bool{"visible": false}) + // Add fixed-size flag but don't set it + rt.Cmd.Flags().Int("fixed-size", 0, "") + got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `"visible":false`) { + t.Fatalf("DryRun missing visible: %s", got) + } + if strings.Contains(got, `fixedSize`) { + t.Fatalf("DryRun should omit fixedSize when not set: %s", got) + } +} + +func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetUpdateDimension, []string{ + "+update-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "1", + "--end-index", "3", + "--visible=true", + "--fixed-size", "50", + "--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 request body: %v", err) + } + props, _ := body["dimensionProperties"].(map[string]interface{}) + if props["visible"] != true { + t.Fatalf("expected visible=true, got: %#v", props) + } + if props["fixedSize"] != float64(50) { + t.Fatalf("expected fixedSize=50, got: %#v", props) + } +} + +func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetUpdateDimension, []string{ + "+update-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "1", + "--end-index", "3", + "--visible=true", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── MoveDimension ──────────────────────────────────────────────────────────── + +func TestSheetMoveDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil) + err := SheetMoveDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--destination-index") { + t.Fatalf("expected destination-index error, got: %v", err) + } +} + +func TestSheetMoveDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil) + if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMoveDimensionValidateWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) + if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMoveDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil) + got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `move_dimension`) { + t.Fatalf("DryRun URL missing move_dimension: %s", got) + } + if !strings.Contains(got, `"major_dimension":"ROWS"`) { + t.Fatalf("DryRun missing major_dimension: %s", got) + } + if !strings.Contains(got, `"start_index":0`) { + t.Fatalf("DryRun missing start_index: %s", got) + } + if !strings.Contains(got, `"destination_index":4`) { + t.Fatalf("DryRun missing destination_index: %s", got) + } +} + +func TestSheetMoveDimensionDryRunWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil) + got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt)) + if !strings.Contains(got, "shtFromURL") { + t.Fatalf("DryRun should extract token from URL: %s", got) + } +} + +func TestSheetMoveDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetMoveDimension, []string{ + "+move-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "0", + "--end-index", "1", + "--destination-index", "4", + "--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 request body: %v", err) + } + source, _ := body["source"].(map[string]interface{}) + if source["major_dimension"] != "ROWS" { + t.Fatalf("unexpected major_dimension: %v", source["major_dimension"]) + } + if body["destination_index"] != float64(4) { + t.Fatalf("unexpected destination_index: %v", body["destination_index"]) + } +} + +func TestSheetMoveDimensionExecuteWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension", + Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, + }) + + err := mountAndRunSheets(t, SheetMoveDimension, []string{ + "+move-dimension", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", + "--dimension", "COLUMNS", + "--start-index", "1", + "--end-index", "2", + "--destination-index", "0", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetMoveDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension", + Status: 400, + Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"}, + }) + + err := mountAndRunSheets(t, SheetMoveDimension, []string{ + "+move-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "0", + "--end-index", "1", + "--destination-index", "4", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} + +// ── DeleteDimension ────────────────────────────────────────────────────────── + +func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 1, "end-index": 3}, nil) + err := SheetDeleteDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") { + t.Fatalf("expected token error, got: %v", err) + } +} + +func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 0, "end-index": 3}, nil) + err := SheetDeleteDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--start-index") { + t.Fatalf("expected start-index error, got: %v", err) + } +} + +func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 5, "end-index": 3}, nil) + err := SheetDeleteDimension.Validate(context.Background(), rt) + if err == nil || !strings.Contains(err.Error(), "--end-index") { + t.Fatalf("expected end-index error, got: %v", err) + } +} + +func TestSheetDeleteDimensionValidateSuccess(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"}, + map[string]int{"start-index": 3, "end-index": 7}, nil) + if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetDeleteDimensionValidateWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 2}, nil) + if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetDeleteDimensionDryRun(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"}, + map[string]int{"start-index": 3, "end-index": 7}, nil) + got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt)) + + if !strings.Contains(got, `"method":"DELETE"`) { + t.Fatalf("DryRun should use DELETE: %s", got) + } + if !strings.Contains(got, `dimension_range`) { + t.Fatalf("DryRun URL missing dimension_range: %s", got) + } + if !strings.Contains(got, `"startIndex":3`) { + t.Fatalf("DryRun missing startIndex: %s", got) + } + if !strings.Contains(got, `"endIndex":7`) { + t.Fatalf("DryRun missing endIndex: %s", got) + } +} + +func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) { + t.Parallel() + rt := newDimTestRuntime(t, + map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"}, + map[string]int{"start-index": 1, "end-index": 5}, nil) + got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt)) + if !strings.Contains(got, "shtFromURL") { + t.Fatalf("DryRun should extract token from URL: %s", got) + } +} + +func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"}, + }, + } + reg.Register(stub) + + err := mountAndRunSheets(t, SheetDeleteDimension, []string{ + "+delete-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "3", + "--end-index", "7", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"delCount"`) { + t.Fatalf("stdout missing delCount: %s", stdout.String()) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("parse request body: %v", err) + } + dim, _ := body["dimension"].(map[string]interface{}) + if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" { + t.Fatalf("unexpected dimension: %#v", dim) + } +} + +func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"}, + }, + }) + + err := mountAndRunSheets(t, SheetDeleteDimension, []string{ + "+delete-dimension", + "--url", "https://example.feishu.cn/sheets/shtFromURL", + "--sheet-id", "sheet1", + "--dimension", "COLUMNS", + "--start-index", "1", + "--end-index", "2", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range", + Status: 400, + Body: map[string]interface{}{"code": 90001, "msg": "invalid request"}, + }) + + err := mountAndRunSheets(t, SheetDeleteDimension, []string{ + "+delete-dimension", + "--spreadsheet-token", "shtTOKEN", + "--sheet-id", "sheet1", + "--dimension", "ROWS", + "--start-index", "3", + "--end-index", "7", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected API error, got nil") + } +} diff --git a/shortcuts/sheets/sheet_insert_dimension.go b/shortcuts/sheets/sheet_insert_dimension.go new file mode 100644 index 00000000..00cec9b9 --- /dev/null +++ b/shortcuts/sheets/sheet_insert_dimension.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetInsertDimension = common.Shortcut{ + Service: "sheets", + Command: "+insert-dimension", + Description: "Insert rows or columns at a specified position", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true}, + {Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if runtime.Int("start-index") < 0 { + return common.FlagErrorf("--start-index must be >= 0") + } + if runtime.Int("end-index") <= runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be greater than --start-index") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + body := map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + } + if s := runtime.Str("inherit-style"); s != "" { + body["inheritStyle"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range"). + Body(body). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + body := map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + } + if s := runtime.Str("inherit-style"); s != "" { + body["inheritStyle"] = s + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)), + nil, body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_move_dimension.go b/shortcuts/sheets/sheet_move_dimension.go new file mode 100644 index 00000000..e769cc90 --- /dev/null +++ b/shortcuts/sheets/sheet_move_dimension.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetMoveDimension = common.Shortcut{ + Service: "sheets", + Command: "+move-dimension", + Description: "Move rows or columns to a new position", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true}, + {Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true}, + {Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if runtime.Int("start-index") < 0 { + return common.FlagErrorf("--start-index must be >= 0") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + if runtime.Int("destination-index") < 0 { + return common.FlagErrorf("--destination-index must be >= 0") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + return common.NewDryRunAPI(). + POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension"). + Body(map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": runtime.Str("dimension"), + "start_index": runtime.Int("start-index"), + "end_index": runtime.Int("end-index"), + }, + "destination_index": runtime.Int("destination-index"), + }). + Set("token", token). + Set("sheet_id", runtime.Str("sheet-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension", + validate.EncodePathSegment(token), + validate.EncodePathSegment(runtime.Str("sheet-id")), + ), + nil, + map[string]interface{}{ + "source": map[string]interface{}{ + "major_dimension": runtime.Str("dimension"), + "start_index": runtime.Int("start-index"), + "end_index": runtime.Int("end-index"), + }, + "destination_index": runtime.Int("destination-index"), + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/sheet_update_dimension.go b/shortcuts/sheets/sheet_update_dimension.go new file mode 100644 index 00000000..a30f506a --- /dev/null +++ b/shortcuts/sheets/sheet_update_dimension.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var SheetUpdateDimension = common.Shortcut{ + Service: "sheets", + Command: "+update-dimension", + Description: "Update row or column properties (visibility, size)", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "spreadsheet URL"}, + {Name: "spreadsheet-token", Desc: "spreadsheet token"}, + {Name: "sheet-id", Desc: "worksheet ID", Required: true}, + {Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}}, + {Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true}, + {Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true}, + {Name: "visible", Type: "bool", Desc: "true to show, false to hide"}, + {Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + if token == "" { + return common.FlagErrorf("specify --url or --spreadsheet-token") + } + if runtime.Int("start-index") < 1 { + return common.FlagErrorf("--start-index must be >= 1") + } + if runtime.Int("end-index") < runtime.Int("start-index") { + return common.FlagErrorf("--end-index must be >= --start-index") + } + if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") { + return common.FlagErrorf("specify at least one of --visible or --fixed-size") + } + if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 { + return common.FlagErrorf("--fixed-size must be >= 1") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + props := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("visible") { + props["visible"] = runtime.Bool("visible") + } + if runtime.Cmd.Flags().Changed("fixed-size") { + props["fixedSize"] = runtime.Int("fixed-size") + } + return common.NewDryRunAPI(). + PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range"). + Body(map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + "dimensionProperties": props, + }). + Set("token", token) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("spreadsheet-token") + if runtime.Str("url") != "" { + token = extractSpreadsheetToken(runtime.Str("url")) + } + + props := map[string]interface{}{} + if runtime.Cmd.Flags().Changed("visible") { + props["visible"] = runtime.Bool("visible") + } + if runtime.Cmd.Flags().Changed("fixed-size") { + props["fixedSize"] = runtime.Int("fixed-size") + } + + data, err := runtime.CallAPI("PUT", + fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)), + nil, + map[string]interface{}{ + "dimension": map[string]interface{}{ + "sheetId": runtime.Str("sheet-id"), + "majorDimension": runtime.Str("dimension"), + "startIndex": runtime.Int("start-index"), + "endIndex": runtime.Int("end-index"), + }, + "dimensionProperties": props, + }, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 60fa5383..3a5cc5ac 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -21,5 +21,10 @@ func Shortcuts() []common.Shortcut { SheetReplace, SheetSetStyle, SheetBatchSetStyle, + SheetAddDimension, + SheetInsertDimension, + SheetUpdateDimension, + SheetMoveDimension, + SheetDeleteDimension, } } diff --git a/skills/lark-sheets/SKILL.md b/skills/lark-sheets/SKILL.md index e2d23d79..6a0c44d3 100644 --- a/skills/lark-sheets/SKILL.md +++ b/skills/lark-sheets/SKILL.md @@ -160,6 +160,11 @@ Shortcut 是对常用操作的高级封装(`lark-cli sheets + [flags]` | [`+replace`](references/lark-sheets-replace.md) | Find and replace cell values | | [`+set-style`](references/lark-sheets-set-style.md) | Set cell style for a range | | [`+batch-set-style`](references/lark-sheets-batch-set-style.md) | Batch set cell styles for multiple ranges | +| [`+add-dimension`](references/lark-sheets-add-dimension.md) | Add rows or columns at the end of a sheet | +| [`+insert-dimension`](references/lark-sheets-insert-dimension.md) | Insert rows or columns at a specified position | +| [`+update-dimension`](references/lark-sheets-update-dimension.md) | Update row or column properties (visibility, size) | +| [`+move-dimension`](references/lark-sheets-move-dimension.md) | Move rows or columns to a new position | +| [`+delete-dimension`](references/lark-sheets-delete-dimension.md) | Delete rows or columns | ## API Resources diff --git a/skills/lark-sheets/references/lark-sheets-add-dimension.md b/skills/lark-sheets/references/lark-sheets-add-dimension.md new file mode 100644 index 00000000..718da33e --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-add-dimension.md @@ -0,0 +1,51 @@ + +# sheets +add-dimension(增加行列) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +add-dimension`。 + +在工作表末尾追加空行或空列,不影响已有数据。 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +# 在末尾追加 10 行 +lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --length 10 + +# 在末尾追加 3 列 +lark-cli sheets +add-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --dimension COLUMNS --length 3 + +# 仅预览参数(不发请求) +lark-cli sheets +add-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --length 5 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | +| `--sheet-id ` | 是 | 工作表 ID | +| `--dimension ` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | +| `--length ` | 是 | 追加数量(1-5000) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含: + +- `addCount`:实际追加的行/列数 +- `majorDimension`:`ROWS` 或 `COLUMNS` + +## 参考 + +- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数 +- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-delete-dimension.md b/skills/lark-sheets/references/lark-sheets-delete-dimension.md new file mode 100644 index 00000000..82a1afd5 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-delete-dimension.md @@ -0,0 +1,53 @@ + +# sheets +delete-dimension(删除行列) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +delete-dimension`。 + +删除指定范围的行或列,已有数据向上或向左移动。 + +> [!CAUTION] +> 这是**破坏性写入操作** —— 删除后数据不可恢复。执行前必须确认用户意图,建议先用 `--dry-run` 预览。 + +## 命令 + +```bash +# 删除第 3-7 行(1-indexed,闭区间) +lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 + +# 删除第 5-8 列 +lark-cli sheets +delete-dimension --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \ + --sheet-id "" --dimension COLUMNS --start-index 5 --end-index 8 + +# 仅预览参数 +lark-cli sheets +delete-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | +| `--sheet-id ` | 是 | 工作表 ID | +| `--dimension ` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | +| `--start-index ` | 是 | 起始位置(**1-indexed**,含) | +| `--end-index ` | 是 | 结束位置(**1-indexed**,含) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON,包含: + +- `delCount`:实际删除的行/列数 +- `majorDimension`:`ROWS` 或 `COLUMNS` + +## 参考 + +- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 增加行列 +- [lark-sheets-insert-dimension](lark-sheets-insert-dimension.md) — 插入行列 +- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-insert-dimension.md b/skills/lark-sheets/references/lark-sheets-insert-dimension.md new file mode 100644 index 00000000..e6cf08b4 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-insert-dimension.md @@ -0,0 +1,51 @@ + +# sheets +insert-dimension(插入行列) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +insert-dimension`。 + +在指定位置插入空行或空列,已有数据向下或向右移动。支持继承相邻行/列样式。 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +# 在第 3 行前插入 4 行空行(0-indexed,插入位置 3~7,不含 7) +lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 3 --end-index 7 + +# 插入列,并继承前方列的样式 +lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension COLUMNS --start-index 2 --end-index 4 \ + --inherit-style BEFORE + +# 仅预览参数 +lark-cli sheets +insert-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 0 --end-index 2 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | +| `--sheet-id ` | 是 | 工作表 ID | +| `--dimension ` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | +| `--start-index ` | 是 | 起始位置(0-indexed) | +| `--end-index ` | 是 | 结束位置(0-indexed,不包含;插入数量 = end - start) | +| `--inherit-style ` | 否 | 样式继承方向:`BEFORE` 继承前方、`AFTER` 继承后方;不传则为空白样式 | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON(成功时 `data` 为空对象 `{}`)。 + +## 参考 + +- [lark-sheets-add-dimension](lark-sheets-add-dimension.md) — 在末尾追加行列 +- [lark-sheets-delete-dimension](lark-sheets-delete-dimension.md) — 删除行列 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-move-dimension.md b/skills/lark-sheets/references/lark-sheets-move-dimension.md new file mode 100644 index 00000000..3398c788 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-move-dimension.md @@ -0,0 +1,52 @@ + +# sheets +move-dimension(移动行列) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +move-dimension`。 + +将指定范围的行/列移动到目标位置。被移动到目标位置后,原本在目标位置的行/列会对应右移或下移。 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +# 将第 0-1 行移动到第 4 行位置 +lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS \ + --start-index 0 --end-index 1 --destination-index 4 + +# 将第 2 列移动到第 0 列位置 +lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension COLUMNS \ + --start-index 2 --end-index 2 --destination-index 0 + +# 仅预览参数 +lark-cli sheets +move-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS \ + --start-index 0 --end-index 1 --destination-index 4 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | +| `--sheet-id ` | 是 | 工作表 ID | +| `--dimension ` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | +| `--start-index ` | 是 | 源起始位置(0-indexed) | +| `--end-index ` | 是 | 源结束位置(0-indexed,含) | +| `--destination-index ` | 是 | 目标位置(0-indexed) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +## 输出 + +JSON(成功时 `data` 为空对象 `{}`)。 + +## 参考 + +- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列数 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-sheets/references/lark-sheets-update-dimension.md b/skills/lark-sheets/references/lark-sheets-update-dimension.md new file mode 100644 index 00000000..d525fb86 --- /dev/null +++ b/skills/lark-sheets/references/lark-sheets-update-dimension.md @@ -0,0 +1,60 @@ + +# sheets +update-dimension(更新行列属性) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +本 skill 对应 shortcut:`lark-cli sheets +update-dimension`。 + +更新指定范围行/列的属性,支持设置显隐状态和行高/列宽。 + +> [!CAUTION] +> 这是**写入操作** —— 执行前必须确认用户意图。可以先用 `--dry-run` 预览。 + +## 命令 + +```bash +# 隐藏第 1-3 行 +lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 1 --end-index 3 \ + --visible=false + +# 设置第 1-5 列列宽为 120px +lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension COLUMNS --start-index 1 --end-index 5 \ + --fixed-size 120 + +# 同时设置显示 + 行高 +lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 1 --end-index 10 \ + --visible=true --fixed-size 50 + +# 仅预览参数 +lark-cli sheets +update-dimension --spreadsheet-token "shtxxxxxxxx" \ + --sheet-id "" --dimension ROWS --start-index 1 --end-index 3 \ + --visible=true --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url ` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) | +| `--spreadsheet-token ` | 否 | 表格 token(与 `--url` 二选一) | +| `--sheet-id ` | 是 | 工作表 ID | +| `--dimension ` | 是 | 操作维度:`ROWS` 或 `COLUMNS` | +| `--start-index ` | 是 | 起始位置(**1-indexed**,含) | +| `--end-index ` | 是 | 结束位置(**1-indexed**,含) | +| `--visible ` | 否 | `true` 显示 / `false` 隐藏(须与 `--fixed-size` 至少传一个) | +| `--fixed-size ` | 否 | 行高或列宽(像素)(须与 `--visible` 至少传一个) | +| `--dry-run` | 否 | 仅打印参数,不执行请求 | + +> **注意**:`--visible` 是 bool flag,传值时使用 `--visible=true` 或 `--visible=false` 格式。 + +## 输出 + +JSON(成功时 `data` 为空对象 `{}`)。 + +## 参考 + +- [lark-sheets-info](lark-sheets-info.md) — 查看当前行列属性 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数