refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema

The 9 row/column-region shortcuts used to share two int flags --start /
--end with inconsistent end semantics across commands — +dim-insert /
-delete / -hide / -unhide / -group / -ungroup treated --end as exclusive,
while +dim-move / +rows-resize / +cols-resize treated it as inclusive.
The skill reference even called this out as "the highest-frequency
off-by-one source", patched in docs rather than at the surface. Three
underlying tool schemas (position+count, A1 range string, 0-based int
pair) were all flattened onto the same --start/--end pair, which forced
a different normaliser per command and pushed mental math (count =
end - start) onto every caller.

Schema (sourced from base, regenerated via sheet-skill-spec, mirrored
into shortcuts/sheets/data/ and skills/lark-sheets/):

  +dim-insert                                    --position + --count
    rows: "3"; columns: "C". --count rows/columns
    inserted *before* --position.
  +dim-delete / -hide / -unhide / -group / -ungroup
                                                 --range
  +rows-resize / +cols-resize                    --range
    A1 closed range. Rows: "3:7" or "5". Columns: "C:F" or "C".
    Mixing letters and digits in one range is rejected.
  +dim-move                                      --source-range + --target
    --target must match --source-range's dimension (both row or both
    column). The move places the source block *before* --target.

Wire-shape preserved: modify_sheet_structure still receives `position`
+ `count` (insert) or a `range` A1 string (other dim-* ops); v3
move_dimension still receives 0-based inclusive ints (CLI parses the
A1 strings into them); resize_range still receives a two-sided A1
range (single-element form is expanded to "N:N" before send).

This is a flag-surface break (--start / --end / --dimension flags
removed from these 9 shortcuts); --dimension stays only on +dim-freeze
since it has no range to derive from.

Code: A1 parser added (parseA1Range / parseA1Position /
letterToColumnIndex reused from write_cells); dimRange / dimRangeFull /
dimPosition deleted; dim-move switches to source-range + target parsing;
resize gains a same-dimension guard so +rows-resize rejects "A:C" with
a clear "+rows-resize expects row numbers" message.

Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun /
TestDimMove_Column / TestDimMove_MismatchedDimension /
TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards
/ TestRangeOperationsShortcuts_DryRun all rewritten against the new
schema. Batch contract trio (BodyMatchesStandalone /
ErrorEquivalence / RejectsBadSubOpInput) and
TestBatchOp_DispatchCoversReportedBugs likewise. Full
`go test ./shortcuts/sheets/` passes.
This commit is contained in:
zhengzhijie
2026-05-27 19:36:07 +08:00
parent e85afd68d2
commit 69d2851163
10 changed files with 514 additions and 429 deletions

View File

@@ -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{})

View File

@@ -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"
},
{

View File

@@ -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)

View File

@@ -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")

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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` —— 行高 / 列宽调整也算行列结构层动作。

View File

@@ -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`(插入起始 index0-based+ `--end`(结束 indexexclusive插入行/列数 = `--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` 查看受影响的范围。