diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index 2335db416..ba4fa9cf9 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -72,20 +72,20 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+dim-insert", sc: DimInsert, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "10", "--end", "12", "--inherit-style", "before"}, - subInput: `{"sheet-id":"sh1","dimension":"row","start":10,"end":12,"inherit-style":"before"}`, + args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"}, + subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`, }, { shortcut: "+dim-delete", sc: DimDelete, - args: []string{"--sheet-id", "sh1", "--dimension", "column", "--start", "2", "--end", "4"}, - subInput: `{"sheet-id":"sh1","dimension":"column","start":2,"end":4}`, + args: []string{"--sheet-id", "sh1", "--range", "C:D"}, + subInput: `{"sheet-id":"sh1","range":"C:D"}`, }, { shortcut: "+dim-hide", sc: DimHide, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "1", "--end", "3"}, - subInput: `{"sheet-id":"sh1","dimension":"row","start":1,"end":3}`, + args: []string{"--sheet-id", "sh1", "--range", "2:3"}, + subInput: `{"sheet-id":"sh1","range":"2:3"}`, }, { shortcut: "+dim-freeze", @@ -96,20 +96,20 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { { shortcut: "+dim-group", sc: DimGroup, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "1", "--end", "5", "--group-state", "fold"}, - subInput: `{"sheet-id":"sh1","dimension":"row","start":1,"end":5,"group-state":"fold"}`, + args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"}, + subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`, }, { shortcut: "+rows-resize", sc: RowsResize, - args: []string{"--sheet-id", "sh1", "--start", "0", "--end", "0", "--type", "pixel", "--size", "30"}, - subInput: `{"sheet-id":"sh1","start":0,"end":0,"type":"pixel","size":30}`, + args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"}, + subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`, }, { shortcut: "+cols-resize", sc: ColsResize, - args: []string{"--sheet-id", "sh1", "--start", "1", "--end", "3", "--type", "standard"}, - subInput: `{"sheet-id":"sh1","start":1,"end":3,"type":"standard"}`, + args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"}, + subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`, }, { shortcut: "+range-move", @@ -377,25 +377,25 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) { { name: "+dim-insert missing sheet selector", shortcut: DimInsert, - args: []string{"--dimension", "row", "--start", "0", "--end", "1"}, + args: []string{"--position", "1", "--count", "1"}, subShortcut: "+dim-insert", - subInput: `{"dimension":"row","start":0,"end":1}`, + subInput: `{"position":"1","count":1}`, wantContains: "specify at least one of --sheet-id or --sheet-name", }, { - name: "+dim-insert --end <= --start", + name: "+dim-insert count <= 0", shortcut: DimInsert, - args: []string{"--sheet-id", "sh1", "--dimension", "row", "--start", "5", "--end", "3"}, + args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"}, subShortcut: "+dim-insert", - subInput: `{"sheet-id":"sh1","dimension":"row","start":5,"end":3}`, - wantContains: "must be greater than --start", + subInput: `{"sheet-id":"sh1","position":"5","count":0}`, + wantContains: "--count must be > 0", }, { name: "+rows-resize --type pixel without --size", shortcut: RowsResize, - args: []string{"--sheet-id", "sh1", "--start", "0", "--end", "1", "--type", "pixel"}, + args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"}, subShortcut: "+rows-resize", - subInput: `{"sheet-id":"sh1","start":0,"end":1,"type":"pixel"}`, + subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`, wantContains: "--type pixel requires --size", }, { @@ -485,15 +485,15 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) { "--range is required", }, { - "+dim-insert missing --dimension", + "+dim-insert missing --position", "+dim-insert", - `{"sheet-id":"sh1","start":0,"end":1}`, - "--dimension is required", + `{"sheet-id":"sh1","count":1}`, + "--position is required", }, { "+rows-resize missing --type", "+rows-resize", - `{"sheet-id":"sh1","start":0,"end":0}`, + `{"sheet-id":"sh1","range":"1:1"}`, "--type is required", }, { @@ -622,10 +622,12 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"]) } - // +rows-resize → resize_range with range + resize_height (not raw start/end). + // +rows-resize → resize_range with range + resize_height. The CLI's single + // "23" input must be expanded to "23:23" because resize_range rejects + // bare single-element ranges. body = parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","start":22,"end":22,"type":"pixel","size":40}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`, "--yes", }) ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index b58b426b6..eb221c4c9 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -607,31 +607,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "dimension", - "kind": "own", - "type": "string", - "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Insert start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Insert end position (exclusive)" - }, { "name": "inherit-style", "kind": "own", @@ -645,6 +620,20 @@ "none" ] }, + { + "name": "position", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position" + }, + { + "name": "count", + "kind": "own", + "type": "int", + "required": "required", + "desc": "Number of rows/columns to insert (must be > 0)" + }, { "name": "dry-run", "kind": "system", @@ -686,29 +675,11 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" + "desc": "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`" }, { "name": "yes", @@ -758,29 +729,11 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" + "desc": "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" }, { "name": "dry-run", @@ -823,29 +776,11 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" + "desc": "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" }, { "name": "dry-run", @@ -945,31 +880,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "dimension", - "kind": "own", - "type": "string", - "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" - }, { "name": "depth", "kind": "own", @@ -990,6 +900,13 @@ "fold" ] }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, { "name": "dry-run", "kind": "system", @@ -1030,31 +947,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "dimension", - "kind": "own", - "type": "string", - "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End position (exclusive)" - }, { "name": "depth", "kind": "own", @@ -1063,6 +955,13 @@ "desc": "Group nesting level to ungroup; default 1 (outermost)", "default": "1" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" + }, { "name": "dry-run", "kind": "system", @@ -1104,36 +1003,18 @@ "desc": "Sheet name (XOR with `--sheet-id`)" }, { - "name": "dimension", + "name": "source-range", "kind": "own", "type": "string", "required": "required", - "desc": "Dimension (row or column)", - "enum": [ - "row", - "column" - ] - }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Source range start position (0-based)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Source range end position (inclusive)" + "desc": "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`" }, { "name": "target", "kind": "own", - "type": "int", + "type": "string", "required": "required", - "desc": "Destination position (move target inserts before this index; 0-based)" + "desc": "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range" }, { "name": "dry-run", @@ -2201,20 +2082,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start row (0-based, inclusive)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End row (0-based, inclusive)" - }, { "name": "type", "kind": "own", @@ -2235,6 +2102,13 @@ "desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", "default": "0" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)" + }, { "name": "dry-run", "kind": "system", @@ -2275,20 +2149,6 @@ "required": "xor", "desc": "Sheet name (XOR with `--sheet-id`)" }, - { - "name": "start", - "kind": "own", - "type": "int", - "required": "required", - "desc": "Start column (0-based, inclusive)" - }, - { - "name": "end", - "kind": "own", - "type": "int", - "required": "required", - "desc": "End column (0-based, inclusive)" - }, { "name": "type", "kind": "own", @@ -2308,6 +2168,13 @@ "desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", "default": "0" }, + { + "name": "range", + "kind": "own", + "type": "string", + "required": "required", + "desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)" + }, { "name": "dry-run", "kind": "system", @@ -3269,7 +3136,7 @@ "kind": "own", "type": "string", "required": "optional", - "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); pairs with `--target-sheet-id`, maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", + "desc": "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", "default": "A1" }, { diff --git a/shortcuts/sheets/execute_paths_test.go b/shortcuts/sheets/execute_paths_test.go index 8b08aaed2..3d5a5bd3b 100644 --- a/shortcuts/sheets/execute_paths_test.go +++ b/shortcuts/sheets/execute_paths_test.go @@ -317,8 +317,9 @@ func TestExecute_WorkbookCreate(t *testing.T) { } // TestExecute_DimMove covers the native v3 move_dimension call. CLI's -// 0-based inclusive --start/--end pass straight through to v3's -// source.{start_index,end_index} (also 0-based inclusive). +// --source-range "1:3" (1-based inclusive) is parsed into v3's +// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is +// parsed into destination_index=10. func TestExecute_DimMove(t *testing.T) { t.Parallel() move := &httpmock.Stub{ @@ -332,7 +333,7 @@ func TestExecute_DimMove(t *testing.T) { } _, err := runShortcutWithStubs(t, DimMove, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + "--source-range", "1:3", "--target", "11", }, move) if err != nil { t.Fatalf("execute failed: %v", err) diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index f190c8d1e..b216a9c0c 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -20,7 +20,7 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) { "--url", testURL, "--operations", `[ {"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}, - {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","dimension":"row","start":0,"end":3}} + {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"1","count":3}} ]`, "--continue-on-error", "--yes", @@ -353,7 +353,7 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) { }, { name: "user filled operation manually", - opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","range":"1:1"}}]`, + opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","position":"1","count":1}}]`, wantMatch: "do not pass input.operation", }, { @@ -430,7 +430,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","start":0,"end":2,"type":"pixel","size":30}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 2e00c11d5..44e8b4eef 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -180,25 +180,24 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg return input, nil } -// resize_range now exposes two CLI shortcuts: +// resize_range exposes two CLI shortcuts: // -// +rows-resize / +cols-resize — set row heights / column widths. The new -// --type enum (pixel / standard / [auto]) replaces the old --size/--reset -// pair; --type pixel still takes a --size pixel value, --type standard -// restores the sheet default, --type auto auto-fits row heights (rows only). +// +rows-resize / +cols-resize — set row heights / column widths. --type +// enum (pixel / standard / [auto]) controls how: --type pixel needs --size, +// --type standard restores the sheet default, --type auto auto-fits row +// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or +// "A:E" / "C" columns); single-element form is expanded to "N:N" before +// send because resize_range rejects bare single-element ranges. // // Wire shape: resize_height / resize_width carries { type, value? }, e.g. // { "type": "pixel", "value": 30 } or { "type": "standard" }. -// -// Both shortcuts share the underlying resize_range tool; --end is inclusive -// in the new CLI surface (was exclusive in the legacy +dim-resize). // RowsResize wraps resize_range for row heights. --type auto enables // auto-fit (rows only); --type pixel requires --size. var RowsResize = common.Shortcut{ Service: "sheets", Command: "+rows-resize", - Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --start/--end are 0-based inclusive).", + Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -238,7 +237,7 @@ var RowsResize = common.Shortcut{ var ColsResize = common.Shortcut{ Service: "sheets", Command: "+cols-resize", - Description: "Resize columns by pixel / standard (--type pixel needs --size; --start/--end are 0-based inclusive; no auto for cols).", + Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -275,7 +274,7 @@ var ColsResize = common.Shortcut{ // validateViaResize wires the standalone Validate to resizeInput so both // paths (standalone + batch sub-op) emit the same error for missing --type, -// out-of-range --start/--end, or --type auto on columns. +// malformed --range, or --type auto on columns. func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error { return func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -297,20 +296,42 @@ func autoSuffix(dimension string) string { return "" } +// commandForDimension returns the shortcut command name a given dimension +// belongs to; used in error messages so users see "+rows-resize" / "+cols-resize" +// instead of the internal "row" / "column" tag. +func commandForDimension(dimension string) string { + if dimension == "row" { + return "+rows-resize" + } + return "+cols-resize" +} + // resizeInput builds the resize_range tool input. dimension is "row" / -// "column"; --end is inclusive on the CLI surface, dimRangeFull wants -// exclusive end, so it is bumped by one here. dimRangeFull (not dimRange) is -// used so a single row/column still emits "N:N" — resize_range rejects a bare -// "N". +// "column" (selected by the calling shortcut); --range must match that +// dimension (row → digits like "2:10" / "5"; column → letters like "A:E" / +// "C"). Single-element form is expanded to "N:N" because resize_range +// rejects bare single-element ranges. func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if !runtime.Changed("start") || !runtime.Changed("end") { - return nil, common.FlagErrorf("--start and --end are required") + if !runtime.Changed("range") { + return nil, common.FlagErrorf("--range is required") } - if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { - return nil, common.FlagErrorf("invalid range: --start (%d) must be >= 0 and --end (%d) must be >= --start", runtime.Int("start"), runtime.Int("end")) + rangeStr := strings.TrimSpace(runtime.Str("range")) + parsedDim, _, _, err := parseA1Range(rangeStr) + if err != nil { + return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) + } + if parsedDim != dimension { + want := "row numbers (e.g. \"2:10\")" + if dimension == "column" { + want = "column letters (e.g. \"A:E\")" + } + return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want) + } + if !strings.Contains(rangeStr, ":") { + rangeStr = rangeStr + ":" + rangeStr } typ := strings.TrimSpace(runtime.Str("type")) if typ == "" { @@ -326,7 +347,6 @@ func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) if typ != "pixel" && hasSize { return nil, common.FlagErrorf("--size is only valid with --type pixel") } - rangeStr := dimRangeFull(dimension, runtime.Int("start"), runtime.Int("end")+1) input := map[string]interface{}{ "excel_id": token, "range": rangeStr, diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 8bc3c171d..1c5c4f9a9 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -67,9 +67,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize --type pixel --size 200", + name: "+rows-resize --range 1:5 pixel 200", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "4", "--type", "pixel", "--size", "200"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -82,9 +82,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize single row (start==end) keeps N:N range", + name: "+rows-resize single row \"1\" expands to \"1:1\"", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "0", "--type", "auto"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1", "--type", "auto"}, toolName: "resize_range", wantInput: map[string]interface{}{ "range": "1:1", @@ -92,9 +92,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+cols-resize --type standard (reset to default)", + name: "+cols-resize --range B:D standard", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "1", "--end", "3", "--type", "standard"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"}, toolName: "resize_range", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -106,9 +106,9 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+cols-resize --type pixel --size 120", + name: "+cols-resize --range A:C pixel 120", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "2", "--type", "pixel", "--size", "120"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"}, toolName: "resize_range", wantInput: map[string]interface{}{ "range": "A:C", @@ -118,6 +118,16 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, }, + { + name: "+cols-resize single column \"C\" expands to \"C:C\"", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C", "--type", "standard"}, + toolName: "resize_range", + wantInput: map[string]interface{}{ + "range": "C:C", + "resize_width": map[string]interface{}{"type": "standard"}, + }, + }, { name: "+range-move cross-sheet", sc: RangeMove, @@ -257,26 +267,38 @@ func TestResize_TypeAndSizeGuards(t *testing.T) { { name: "+rows-resize --type pixel without --size", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "3", "--type", "pixel"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"}, want: "--type pixel requires --size", }, { name: "+rows-resize --type standard with --size", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "3", "--type", "standard", "--size", "30"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"}, want: "--size is only valid with --type pixel", }, { name: "+cols-resize rejects --type auto", sc: ColsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "2", "--type", "auto"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "auto"}, want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard" }, { - name: "--end < --start", + name: "+rows-resize given column range", sc: RowsResize, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "5", "--end", "3", "--type", "standard"}, - want: "must be >= --start", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "standard"}, + want: "+rows-resize expects row numbers", + }, + { + name: "+cols-resize given row range", + sc: ColsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard"}, + want: "+cols-resize expects column letters", + }, + { + name: "+rows-resize end < start", + sc: RowsResize, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--type", "standard"}, + want: "end position is before start", }, } for _, tt := range cases { diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index d4c477a27..aa41f5d6a 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -6,6 +6,7 @@ package sheets import ( "context" "fmt" + "strconv" "strings" "github.com/larksuite/cli/internal/validate" @@ -15,9 +16,12 @@ import ( // ─── lark_sheet_sheet_structure ─────────────────────────────────────── // // Wraps get_sheet_structure (read) and modify_sheet_structure (write, -// operation-enum dispatch). CLI's --start/--end are 0-based with exclusive -// end; the tool wants 1-based inclusive row numbers ("3:7") or column -// letters ("C:F"). The conversion lives in dimRange / dimPosition below. +// operation-enum dispatch). All region/position arguments use A1-style +// strings (1-based row numbers like "3:7" / "5", or column letters like +// "C:F" / "C"); dim-* / resize never expose 0-based int indices on the CLI +// surface, so there is no inclusive/exclusive ambiguity across commands. +// parseA1Range / parseA1Position handle parsing into the 0-based ints that +// dim-move's native v3 endpoint expects. // // +rows-resize / +cols-resize live in lark_sheet_range_operations (different // tool); they are only grouped under "工作表" for discoverability. @@ -118,7 +122,7 @@ func infoTypeFromInclude(include []string) string { var DimInsert = common.Shortcut{ Service: "sheets", Command: "+dim-insert", - Description: "Insert blank rows or columns at a given range.", + Description: "Insert blank rows or columns at a given position.", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, @@ -153,21 +157,31 @@ var DimInsert = common.Shortcut{ }, } +// dimInsertInput passes --position (1-based row number "3" or column letter +// "C") straight to the tool's `position` field; --count maps to `count`. func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if err := requireDimRange(runtime); err != nil { - return nil, err + if !runtime.Changed("position") { + return nil, common.FlagErrorf("--position is required") + } + if !runtime.Changed("count") { + return nil, common.FlagErrorf("--count is required") + } + position := strings.TrimSpace(runtime.Str("position")) + if _, _, err := parseA1Position(position); err != nil { + return nil, common.FlagErrorf("invalid --position %q: %v", position, err) + } + count := runtime.Int("count") + if count <= 0 { + return nil, common.FlagErrorf("--count must be > 0 (got %d)", count) } - dim := runtime.Str("dimension") - start := runtime.Int("start") - end := runtime.Int("end") input := map[string]interface{}{ "excel_id": token, "operation": "insert", - "position": dimPosition(dim, start), - "count": end - start, + "position": position, + "count": count, } sheetSelectorForToolInput(input, sheetID, sheetName) switch runtime.Str("inherit-style") { @@ -338,40 +352,25 @@ func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[str return input, nil } -// requireDimRange validates the dimension/start/end triple shared by -// insert/delete/hide/unhide/group/ungroup. Pure flag-level checks — the sheet -// selector and token live in their own helpers. -func requireDimRange(runtime flagView) error { - if !runtime.Changed("dimension") { - return common.FlagErrorf("--dimension is required") - } - if !runtime.Changed("start") || !runtime.Changed("end") { - return common.FlagErrorf("--start and --end are required") - } - start := runtime.Int("start") - end := runtime.Int("end") - if start < 0 { - return common.FlagErrorf("--start must be >= 0") - } - if end <= start { - return common.FlagErrorf("--end (%d) must be greater than --start (%d)", end, start) - } - return nil -} - -// dimRangeOpInput builds the tool input for delete/hide/unhide which all -// take a `range` field. dimRange handles 0-based exclusive → 1-based inclusive. +// dimRangeOpInput builds the tool input for delete/hide/unhide/group/ungroup +// which all take a `range` string field. --range is a 1-based A1 closed range +// ("3:7" / "5" for rows, "C:F" / "C" for columns) and passes straight through +// after format validation. func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if err := requireDimRange(runtime); err != nil { - return nil, err + if !runtime.Changed("range") { + return nil, common.FlagErrorf("--range is required") + } + rangeStr := strings.TrimSpace(runtime.Str("range")) + if _, _, _, err := parseA1Range(rangeStr); err != nil { + return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err) } input := map[string]interface{}{ "excel_id": token, "operation": op, - "range": dimRange(runtime.Str("dimension"), runtime.Int("start"), runtime.Int("end")), + "range": rangeStr, } sheetSelectorForToolInput(input, sheetID, sheetName) return input, nil @@ -475,48 +474,75 @@ func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[ return input, nil } -// ─── dimension formatting helpers ───────────────────────────────────── +// ─── A1 parsing helpers ─────────────────────────────────────────────── -// dimRange formats a CLI (0-based exclusive end) range as the tool's -// 1-based inclusive A1-style range string. row → "3:7", column → "C:F". -// A single-element range collapses to "3" / "C". -func dimRange(dimension string, start, end int) string { - if dimension == "column" { - startLetter := columnIndexToLetter(start) - endLetter := columnIndexToLetter(end - 1) - if start == end-1 { - return startLetter +// parseA1Range parses an A1 closed range ("3:7" / "5" / "C:F" / "C") into +// the inferred dimension ("row" or "column") and 0-based inclusive indices. +// Single-element form yields startIdx == endIdx. Mixing digits and letters +// across the two sides ("3:C") is rejected. +func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return "", 0, 0, fmt.Errorf("range is empty") + } + parts := strings.Split(s, ":") + if len(parts) > 2 { + return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element") + } + dim1, idx1, err := parseA1Position(parts[0]) + if err != nil { + return "", 0, 0, err + } + if len(parts) == 1 { + return dim1, idx1, idx1, nil + } + dim2, idx2, err := parseA1Position(parts[1]) + if err != nil { + return "", 0, 0, err + } + if dim1 != dim2 { + return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range") + } + if idx2 < idx1 { + return "", 0, 0, fmt.Errorf("end position is before start") + } + return dim1, idx1, idx2, nil +} + +// parseA1Position parses a single A1 position element: pure digits → row +// (1-based number, returned as 0-based idx); pure letters → column (letters +// case-insensitive, "A" → 0, "AA" → 26). +func parseA1Position(s string) (dimension string, idx int, err error) { + s = strings.TrimSpace(s) + if s == "" { + return "", 0, fmt.Errorf("position is empty") + } + isDigits := true + isLetters := true + for _, r := range s { + if r < '0' || r > '9' { + isDigits = false + } + if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) { + isLetters = false } - return startLetter + ":" + endLetter } - if start == end-1 { - return fmt.Sprintf("%d", start+1) + if isDigits { + n, _ := strconv.Atoi(s) + if n <= 0 { + return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s) + } + return "row", n - 1, nil } - return fmt.Sprintf("%d:%d", start+1, end) -} - -// dimRangeFull is like dimRange but never collapses a single-element range to -// a bare index — it always emits the two-sided "N:N" / "C:C" form. resize_range -// rejects a bare index ("23" → Invalid range), so single-row/column resizes -// must keep both sides. -func dimRangeFull(dimension string, start, end int) string { - if dimension == "column" { - return columnIndexToLetter(start) + ":" + columnIndexToLetter(end-1) + if isLetters { + return "column", letterToColumnIndex(s), nil } - return fmt.Sprintf("%d:%d", start+1, end) -} - -// dimPosition formats a single CLI 0-based index as the tool's 1-based row -// number string or column letter. -func dimPosition(dimension string, idx int) string { - if dimension == "column" { - return columnIndexToLetter(idx) - } - return fmt.Sprintf("%d", idx+1) + return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s) } // columnIndexToLetter converts a 0-based column index to the spreadsheet // letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA"). +// Used by +workbook helpers that need to format absolute column references. func columnIndexToLetter(idx int) string { if idx < 0 { return "" @@ -535,13 +561,9 @@ func columnIndexToLetter(idx int) string { // // Moves a contiguous block of rows or columns to a new index in the same // sheet via the native v3 move_dimension endpoint (not the One-OpenAPI -// dispatcher). CLI's --start / --end are 0-based inclusive; v3 -// move_dimension's source.{start_index,end_index} are likewise 0-based -// inclusive, so they pass straight through. The earlier build POSTed a -// {source,destinationIndex} body to the v2 dimension_range endpoint, which -// is the add/update/delete surface and expects a `dimension` object — -// hence the server rejected it with "[9499] Missing required parameter: -// Dimension". +// dispatcher). CLI accepts --source-range (A1 closed range like "3:7" or +// "C:F") + --target (A1 single position like "12" or "H"); both are parsed +// into the 0-based int indices that v3 move_dimension expects. var DimMove = common.Shortcut{ Service: "sheets", @@ -559,16 +581,8 @@ var DimMove = common.Shortcut{ if _, _, err := resolveSheetSelector(runtime); err != nil { return err } - if !runtime.Changed("dimension") || !runtime.Changed("start") || !runtime.Changed("end") || !runtime.Changed("target") { - return common.FlagErrorf("--dimension / --start / --end / --target are all required") - } - if runtime.Int("start") < 0 || runtime.Int("end") < runtime.Int("start") { - return common.FlagErrorf("--end (%d) must be >= --start (%d) (both 0-indexed, inclusive)", runtime.Int("end"), runtime.Int("start")) - } - if runtime.Int("target") < 0 { - return common.FlagErrorf("--target must be >= 0") - } - return nil + _, err := buildDimMovePlan(runtime) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) @@ -606,6 +620,36 @@ var DimMove = common.Shortcut{ }, } +// dimMovePlan is the parsed form of --source-range / --target. +type dimMovePlan struct { + dimension string // "row" / "column" + startIdx int // 0-based inclusive + endIdx int // 0-based inclusive + targetIdx int // 0-based; destination position (move inserts before this) +} + +// buildDimMovePlan parses --source-range + --target and enforces that the +// target dimension matches the source. Used by both Validate and Execute. +func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) { + if !runtime.Changed("source-range") || !runtime.Changed("target") { + return nil, common.FlagErrorf("--source-range and --target are required") + } + src := strings.TrimSpace(runtime.Str("source-range")) + dim, startIdx, endIdx, err := parseA1Range(src) + if err != nil { + return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err) + } + tgt := strings.TrimSpace(runtime.Str("target")) + tgtDim, tgtIdx, err := parseA1Position(tgt) + if err != nil { + return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err) + } + if tgtDim != dim { + return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim) + } + return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil +} + // dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in // the path (unlike the v2 dimension_range body that the earlier build used). func dimMovePath(token, sheetID string) string { @@ -614,16 +658,22 @@ func dimMovePath(token, sheetID string) string { } func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} { + plan, err := buildDimMovePlan(runtime) + if err != nil { + // Validate has already rejected this case; emit an empty body + // rather than panic on the dry-run path. + return map[string]interface{}{} + } dim := "ROWS" - if runtime.Str("dimension") == "column" { + if plan.dimension == "column" { dim = "COLUMNS" } return map[string]interface{}{ "source": map[string]interface{}{ "major_dimension": dim, - "start_index": runtime.Int("start"), - "end_index": runtime.Int("end"), // both CLI --end and v3 end_index are 0-based inclusive + "start_index": plan.startIdx, + "end_index": plan.endIdx, }, - "destination_index": runtime.Int("target"), + "destination_index": plan.targetIdx, } } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index d5443ef81..6d9d8f62c 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -11,8 +11,10 @@ import ( ) // TestSheetStructureShortcuts_DryRun covers all 8 shortcuts in -// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies the -// CLI 0-based exclusive-end → tool 1-based inclusive A1 conversion. +// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies that the +// CLI's A1-style --range / --position / --count flags map straight through +// to the tool's `range` / `position` / `count` fields (or are normalised +// per shortcut's wire shape). func TestSheetStructureShortcuts_DryRun(t *testing.T) { t.Parallel() @@ -46,9 +48,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-insert row 5..8 inherit-before → position 6 + count 3 + side", + name: "+dim-insert row position=6 count=3 inherit-before", sc: DimInsert, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "5", "--end", "8", "--inherit-style", "before"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -60,9 +62,22 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-delete column B..D", + name: "+dim-insert column position=C count=2", + sc: DimInsert, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "C", "--count", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "insert", + "sheet_id": testSheetID, + "position": "C", + "count": float64(2), + }, + }, + { + name: "+dim-delete column B:D", sc: DimDelete, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "1", "--end", "4"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -72,9 +87,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-hide row 2..5 → range 3:5", + name: "+dim-hide row 3:5", sc: DimHide, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "2", "--end", "5"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:5"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -84,9 +99,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-unhide column 26..29 → AA:AC", + name: "+dim-unhide column AA:AC", sc: DimUnhide, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--start", "26", "--end", "29"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "AA:AC"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -119,9 +134,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-group with state", + name: "+dim-group row 1:5 fold", sc: DimGroup, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5", "--group-state", "fold"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--group-state", "fold"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -132,9 +147,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { }, }, { - name: "+dim-ungroup", + name: "+dim-ungroup row 1:5", sc: DimUngroup, - args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--start", "0", "--end", "5"}, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"}, toolName: "modify_sheet_structure", wantInput: map[string]interface{}{ "excel_id": testToken, @@ -155,29 +170,56 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { } } -func TestDimRange_StartEndValidation(t *testing.T) { +// TestDimRange_Validation covers the A1 range parser's edge cases routed +// through +dim-hide (any --range shortcut works; we just need to exercise +// the validator). +func TestDimRange_Validation(t *testing.T) { t.Parallel() - stdout, stderr, err := runShortcutCapturingErr(t, DimHide, []string{ - "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "5", "--end", "3", "--dry-run", - }) - if err == nil { - t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + cases := []struct { + name string + args []string + want string + }{ + { + name: "end before start", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--dry-run"}, + want: "end position is before start", + }, + { + name: "mix row+column", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:C", "--dry-run"}, + want: "cannot mix row", + }, + { + name: "invalid characters", + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--dry-run"}, + want: "expected pure digits", + }, } - if !strings.Contains(stdout+stderr+err.Error(), "must be greater than --start") { - t.Errorf("expected end>start guard; got=%s|%s|%v", stdout, stderr, err) + for _, tt := range cases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), tt.want) { + t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err) + } + }) } } // TestDimMove_DryRun verifies the native v3 move_dimension payload shape. -// CLI's 0-based inclusive (--start / --end) maps straight through to v3's -// source.{start_index,end_index} (also 0-based inclusive), and sheet_id is -// carried in the path, not the body. +// CLI's --source-range "1:3" (1-based inclusive) is parsed into +// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is +// carried in the path, not the body. --target "11" → destination_index=10. func TestDimMove_DryRun(t *testing.T) { t.Parallel() calls := parseDryRunAPI(t, DimMove, []string{ "--url", testURL, "--sheet-id", testSheetID, - "--dimension", "row", "--start", "0", "--end", "2", "--target", "10", + "--source-range", "1:3", "--target", "11", }) if len(calls) != 1 { t.Fatalf("api calls = %d, want 1", len(calls)) @@ -196,15 +238,96 @@ func TestDimMove_DryRun(t *testing.T) { t.Errorf("start_index = %v, want 0", src["start_index"]) } if src["end_index"].(float64) != 2 { - t.Errorf("end_index = %v, want 2 (0-based inclusive, passes straight through)", src["end_index"]) + t.Errorf("end_index = %v, want 2 (0-based inclusive)", src["end_index"]) } if body["destination_index"].(float64) != 10 { - t.Errorf("destination_index = %v, want 10", body["destination_index"]) + t.Errorf("destination_index = %v, want 10 (target \"11\" → 0-based 10)", body["destination_index"]) } } -// TestColumnIndexToLetter exercises the corner cases of the letter helper: -// single, double, and triple-letter spans. +// TestDimMove_Column exercises the column path: --source-range "C:F" → +// COLUMNS / start=2 / end=5; --target "H" → destination_index=7. +func TestDimMove_Column(t *testing.T) { + t.Parallel() + calls := parseDryRunAPI(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "C:F", "--target", "H", + }) + c := calls[0].(map[string]interface{}) + body, _ := c["body"].(map[string]interface{}) + src, _ := body["source"].(map[string]interface{}) + if src["major_dimension"] != "COLUMNS" { + t.Errorf("major_dimension = %v, want COLUMNS", src["major_dimension"]) + } + if src["start_index"].(float64) != 2 || src["end_index"].(float64) != 5 { + t.Errorf("source = %v, want start=2 end=5", src) + } + if body["destination_index"].(float64) != 7 { + t.Errorf("destination_index = %v, want 7", body["destination_index"]) + } +} + +// TestDimMove_MismatchedDimension verifies that mixing source row + target +// column (or vice versa) is rejected at Validate. +func TestDimMove_MismatchedDimension(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{ + "--url", testURL, "--sheet-id", testSheetID, + "--source-range", "1:3", "--target", "H", "--dry-run", + }) + if err == nil { + t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr) + } + if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") { + t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err) + } +} + +// TestParseA1Range covers parser edge cases directly. +func TestParseA1Range(t *testing.T) { + t.Parallel() + cases := []struct { + in string + dim string + start int + end int + wantErr bool + }{ + {"3:7", "row", 2, 6, false}, + {"5", "row", 4, 4, false}, + {"C:F", "column", 2, 5, false}, + {"C", "column", 2, 2, false}, + {"aa:ac", "column", 26, 28, false}, // lower-case letters accepted + {"", "", 0, 0, true}, + {"3:C", "", 0, 0, true}, + {"7:3", "", 0, 0, true}, + {"A1", "", 0, 0, true}, // cell ref, not a row/col range + {"3:5:7", "", 0, 0, true}, + {"0", "", 0, 0, true}, // rows are 1-based + } + for _, c := range cases { + c := c + t.Run(c.in, func(t *testing.T) { + t.Parallel() + dim, start, end, err := parseA1Range(c.in) + if c.wantErr { + if err == nil { + t.Errorf("parseA1Range(%q) = (%q, %d, %d, nil), want error", c.in, dim, start, end) + } + return + } + if err != nil { + t.Fatalf("parseA1Range(%q) unexpected error: %v", c.in, err) + } + if dim != c.dim || start != c.start || end != c.end { + t.Errorf("parseA1Range(%q) = (%q, %d, %d), want (%q, %d, %d)", c.in, dim, start, end, c.dim, c.start, c.end) + } + }) + } +} + +// TestColumnIndexToLetter exercises the corner cases of the letter helper +// (still in use by lark_sheet_workbook.go for absolute column refs). func TestColumnIndexToLetter(t *testing.T) { t.Parallel() cases := []struct { diff --git a/skills/lark-sheets/references/lark-sheets-range-operations.md b/skills/lark-sheets/references/lark-sheets-range-operations.md index 9e4793eb9..3370f3e5f 100644 --- a/skills/lark-sheets/references/lark-sheets-range-operations.md +++ b/skills/lark-sheets/references/lark-sheets-range-operations.md @@ -123,10 +123,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | required | 起始行(0-based, inclusive) | -| `--end` | int | required | 结束行(0-based, inclusive) | | `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准行高)/ `auto`(自动适应内容)(可选值:`pixel` / `standard` / `auto`) | | `--size` | int | optional | 行高(像素,例:30 / 40 / 60);`--type pixel` 时必填,其它 type 忽略 | +| `--range` | string | required | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5` | ### `+cols-resize` @@ -134,10 +133,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--start` | int | required | 起始列(0-based, inclusive) | -| `--end` | int | required | 结束列(0-based, inclusive) | | `--type` | string | required | 尺寸方式 enum:`pixel`(指定 px 像素值,需配 `--size`)/ `standard`(重置为默认标准列宽)(可选值:`pixel` / `standard`) | | `--size` | int | optional | 列宽(像素,例:80 / 120 / 200);`--type pixel` 时必填,其它 type 忽略 | +| `--range` | string | required | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C` | ### `+range-move` @@ -224,16 +222,16 @@ lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100" ```bash # 把第 2-10 行设为固定 30 px -lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 2 --end 10 --type pixel --size 30 +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --type pixel --size 30 # 把 A-C 列设为固定 120 px -lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 2 --type pixel --size 120 +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --type pixel --size 120 -# 行高自动适应内容(列宽不支持 auto) -lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --start 0 --end 0 --type auto +# 第 1 行行高自动适应内容(列宽不支持 auto) +lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto -# 重置为默认 -lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --start 0 --end 5 --type standard +# 重置 A-E 列为默认列宽 +lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard ``` > 同时出现在 `lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index f833efb33..16473e3b7 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -17,31 +17,31 @@ | 操作需求 | 使用工具 | 说明 | |---------|---------|------| | 查看子表布局 | `+sheet-info` | 获取行高、列宽、隐藏行列、行列分组、合并单元格等信息 | -| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` | 插入/删除/隐藏/取消隐藏/冻结行列、行列分组操作 | +| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup|move}` | 插入/删除/隐藏/取消隐藏/冻结/分组/移动行列 | 注意: - 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义 - 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容 -- 插入用 `+dim-insert`:`--dimension`(`row`/`column`)+ `--start`(插入起始 index,0-based)+ `--end`(结束 index,exclusive);插入行/列数 = `--end` − `--start`。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`) -- 处理"在第 N 行后追加"这类请求时,注意 `--start` 是 0-based 索引、`--end` 是 exclusive,换算时避免 off-by-one -- 例如"在第 20 行后新增 116 行":`--dimension row --start 21 --end 137`("第 20 行后"即从 index 21 起插入,`--end` = `--start` + 116) +- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before`/`after`/`none`) +- 例如"在第 20 行后新增 116 行":`--position 21 --count 116`("第 20 行后"即 1-based 行号 21) -**⚠️ `--end` 区间端点语义对照(跨命令不一致,最高发的 off-by-one 来源)**:同样叫 `--start` / `--end`、同样作用于行/列区间,但 `--end` 含义因命令而异,构造参数前务必对照本表: +**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**: -| 命令 | `--end` 语义 | 备注 | +| 命令 | 用什么 flag 表达区间 / 位置 | 例子 | | --- | --- | --- | -| `+dim-insert` / `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` | **exclusive**(不含 end) | 操作行/列数 = `--end` − `--start` | -| `+dim-move` | **inclusive**(含 end) | ⚠️ 与同族 `+dim-*` **相反**!`--start`/`--end` 是**源区间**(闭区间),目标位置另用 `--target` | -| `+rows-resize` / `+cols-resize` | **inclusive**(含 end) | `--start`/`--end` 均为 0-based 闭区间 | +| `+dim-insert` | `--position` + `--count` | `--position 3 --count 5`(在第 3 行前插 5 行)/ `--position C --count 2`(在 C 列前插 2 列) | +| `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` / `+rows-resize` / `+cols-resize` | `--range` | `"3:7"`(第 3-7 行,闭区间)/ `"C:F"`(C-F 列,闭区间)/ `"5"` 或 `"C"`(单行/列) | +| `+dim-move` | `--source-range`(源区间)+ `--target`(目标位置) | `--source-range "3:7" --target 12`(把第 3-7 行移到第 12 行前)/ `--source-range "C:F" --target H` | -把 `+dim-insert` / `+dim-delete` 的 exclusive 习惯照搬到 `+dim-move` / `+rows-resize` / `+cols-resize`(或反过来)会少算/多算一行/一列——动手前先在本表确认目标命令的 `--end` 端点语义。 +行用 1-based 数字、列用字母——跟 Excel / 飞书 UI 看到的行号、列字母完全一致。 **常见配置错误(必须注意)**: -- **插入列位置偏移**:插入列时 `--start` 是基于 0 的列索引,不是列字母。插入前先通过 `+workbook-info` 或读取表头确认目标位置的实际列索引,不要凭猜测 -- **插入后引用偏移**:插入行/列后,原有数据的行列号会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的行列号 -- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--start` / `--end` 精确无误(`+dim-delete` 用 `--dimension` + `--start`(0-based)+ `--end`(exclusive))。可先用 `+csv-get` 读取目标区域验证内容 -- **"在左侧新增一列"的正确写法**:用户说"在 D 列左侧新增一列"时,`--dimension column`、`--start` 取 D 列的 0-based 索引(新列插在该 index 之前)、`--end = --start + 1`;要继承左侧列样式加 `--inherit-style before` +- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引 +- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置 +- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容 +- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before` +- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用 - **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续 - **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1 @@ -76,10 +76,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 插入起始位置(0-based) | -| `--end` | int | required | 插入结束位置(exclusive) | | `--inherit-style` | string | optional | 新行/列样式继承策略 enum:`before`(继承前一行/列)/ `after`(继承后一行/列)/ `none`(默认)(可选值:`before` / `after` / `none`) | +| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` | +| `--count` | int | required | 插入数量(>0) | ### `+dim-delete` @@ -87,9 +86,7 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | +| `--range` | string | required | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C` | ### `+dim-hide` @@ -97,9 +94,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | +| `--range` | string | required | 要隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-unhide` @@ -107,9 +102,7 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | +| `--range` | string | required | 要取消隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-freeze` @@ -126,11 +119,9 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 嵌套分组的层级(创建到第几层),默认 1 | | `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand`) | +| `--range` | string | required | 要创建分组的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-ungroup` @@ -138,10 +129,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 起始位置(0-based) | -| `--end` | int | required | 结束位置(exclusive) | | `--depth` | int | optional | 要取消的分组层级,默认 1(最外层) | +| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` | ### `+dim-move` @@ -149,10 +138,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--start` | int | required | 源起止区间的起始位置(0-based) | -| `--end` | int | required | 源起止区间的结束位置(inclusive) | -| `--target` | int | required | 目标位置(move 到该 index 之前;0-based) | +| `--source-range` | string | required | 要移动的源行/列闭区间;行如 `3:7`,列如 `C:F` | +| `--target` | string | required | 目标位置(移到此行/列**之前**):行用 1-based 行号如 `12`,列用字母如 `H`。必须与 `--source-range` 同维度(行/列) | ## Examples @@ -164,26 +151,41 @@ _公共四件套 · 系统:`--dry-run`_ ### `+dim-insert` -示例: - ```bash # 在第 10 行前插 3 行,继承上方样式 lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \ - --sheet-id "$SID" --dimension row --start 10 --end 13 --inherit-style before + --sheet-id "$SID" --position 10 --count 3 --inherit-style before + +# 在 C 列前插 2 列 +lark-cli sheets +dim-insert --url "..." --sheet-id "$SID" --position C --count 2 ``` ### `+dim-delete` ```bash -# 删除第 5-7 行(0-based,--end 为 exclusive) -lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 --yes +# 删除第 5-7 行 +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes + +# 删除 D-F 列 +lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes ``` ### `+dim-hide` / `+dim-unhide` ```bash -lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 -lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --dimension row --start 5 --end 8 +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "5:7" +lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --range "5:7" +lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "C:F" +``` + +### `+dim-move` + +```bash +# 把第 3-7 行移到第 12 行前 +lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "3:7" --target 12 + +# 把 C-F 列移到 H 列前 +lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --target H ``` ### `+rows-resize` / `+cols-resize` @@ -205,6 +207,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--start ≤ --end`;`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+rows-resize` / `+cols-resize` 的行 vs 列 `--type` 差异详见 `lark-sheets-range-operations.md`。 -- `DryRun`:写操作输出"将要 PATCH 的 dimension 区间 + 目标参数"。 +- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`;`+rows-resize` / `+cols-resize` 的 `--type` 必填,`--type pixel` 时 `--size` 必填、其它 type 时 `--size` 会被忽略(传了无害);`+rows-resize` / `+cols-resize` 的行 vs 列 `--type` 差异详见 `lark-sheets-range-operations.md`。 +- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。 - `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。