From 3624499bcbcd0b645ebee391f5a6cf50fb4915c4 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Fri, 31 Jul 2026 18:41:23 +0800 Subject: [PATCH] feat(sheets): +dim-freeze --rows/--cols for both axes in one call Freeze is full-state replacement server-side, so --dimension/--count unfreezes the axis it does not name. Two calls cannot hold both axes, and inside +batch-update there was no way at all: sub-ops are a static array that cannot read the current state, and +styles-put is not batchable. --rows/--cols state the complete freeze state in one operation, so the standalone command and the batch sub-op keep producing identical bodies. The legacy pair still works and prints the exact --rows/--cols equivalent on use, but is hidden from --help and from the skill docs. --- shortcuts/sheets/batch_op_contract_test.go | 9 ++ shortcuts/sheets/data/flag-defs.json | 24 ++- shortcuts/sheets/flag_defs_gen.go | 6 +- .../sheets/lark_sheet_sheet_structure.go | 126 ++++++++++++---- .../sheets/lark_sheet_sheet_structure_test.go | 139 ++++++++++++++++++ .../references/lark-sheets-sheet-structure.md | 15 +- 6 files changed, 283 insertions(+), 36 deletions(-) diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go index f44f67849..59a59a334 100644 --- a/shortcuts/sheets/batch_op_contract_test.go +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -93,6 +93,15 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) { args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"}, subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`, }, + { + // The both-axes form has to hold inside a batch too: it is the only + // way to freeze rows AND columns there, since +styles-put (the other + // carrier of a combined freeze) is not a batchable sub-op. + shortcut: "+dim-freeze", + sc: DimFreeze, + args: []string{"--sheet-id", "sh1", "--rows", "1", "--cols", "2"}, + subInput: `{"sheet-id":"sh1","rows":1,"cols":2}`, + }, { shortcut: "+dim-group", sc: DimGroup, diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index b8b407920..824a64646 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1044,8 +1044,9 @@ "name": "dimension", "kind": "own", "type": "string", - "required": "required", - "desc": "Dimension (row or column)", + "required": "optional", + "desc": "[legacy] Dimension (row or column), paired with --count; sets one axis only and unfreezes the other. Prefer --rows / --cols", + "hidden": true, "enum": [ "row", "column" @@ -1055,8 +1056,23 @@ "name": "count", "kind": "own", "type": "int", - "required": "required", - "desc": "Freeze the first N rows/columns; pass 0 to unfreeze" + "required": "optional", + "desc": "[legacy] Freeze the first N rows/columns (paired with --dimension); 0 clears all freezing. Equivalent to --rows N / --cols N, and only --rows/--cols can hold both axes at once", + "hidden": true + }, + { + "name": "rows", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Freeze the first N rows; together with --cols this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen rows)" + }, + { + "name": "cols", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Freeze the first N columns; together with --rows this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen columns)" }, { "name": "dry-run", diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 6751e3591..0780b924e 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -361,8 +361,10 @@ var flagDefs = map[string]commandDef{ {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"}, - {Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}}, - {Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"}, + {Name: "dimension", Kind: "own", Type: "string", Required: "optional", Desc: "[legacy] Dimension (row or column), paired with --count; sets one axis only and unfreezes the other. Prefer --rows / --cols", Hidden: true, Enum: []string{"row", "column"}}, + {Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "[legacy] Freeze the first N rows/columns (paired with --dimension); 0 clears all freezing. Equivalent to --rows N / --cols N, and only --rows/--cols can hold both axes at once", Hidden: true}, + {Name: "rows", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N rows; together with --cols this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen rows)"}, + {Name: "cols", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N columns; together with --rows this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen columns)"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index 9b4992e47..3738f901c 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -438,19 +438,23 @@ var DimUngroup = newDimGroupShortcut( "+dim-ungroup", "Remove a row/column outline group.", "ungroup", ) -// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that -// dimension. +// DimFreeze sets the sheet's freeze state. Freeze is full-state replacement +// server-side (verified 07-31 live), so every call states the WHOLE state: +// --rows/--cols name both axes at once, while the older --dimension/--count +// pair can only name one and therefore unfreezes the other. var DimFreeze = common.Shortcut{ Service: "sheets", Command: "+dim-freeze", - Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.", + Description: "Freeze the first N rows and/or columns; this sets the whole freeze state, so an axis you do not name ends up unfrozen.", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dim-freeze"), Tips: []string{ - "Example: lark-cli sheets +dim-freeze --url --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)", + "Example: lark-cli sheets +dim-freeze --url --sheet-name Sheet1 --rows 1 --cols 2 (holds the header row and the first 2 columns in one call)", + "Freezing is not additive: --dimension row --count 1 followed by --dimension column --count 2 leaves ONLY the columns frozen. Pass --rows/--cols together instead of calling twice", + "To unfreeze one axis but keep the other, state the survivor: --rows 0 --cols 2. Bare --count 0 clears both", }, Validate: validateViaInput(dimFreezeInput), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -472,6 +476,23 @@ var DimFreeze = common.Shortcut{ if err != nil { return err } + // DEPRECATED(phase-2): +dim-freeze --dimension / --count — replaced by + // --rows / --cols. Phase 1 (here): the flags keep working, are retired + // from the skill docs via bundle.json doc_hidden_flags in + // sheet-skill-spec, and every use prints the exact replacement below. + // Phase 2 removal: drop both rows from spec-tables/flags.json + their + // doc_hidden_flags entry, then this block, dimFreezeEquivalent, and the + // legacy branch in dimFreezeInput. + // + // The pair is a strict subset of --rows/--cols — every + // --dimension/--count call has a byte-identical --rows/--cols spelling + // (TestDimFreezeEquivalent pins this) — and it is the form that reads as + // if it scoped to one axis when the backend replaces the whole state. + if runtime.Changed("dimension") || runtime.Changed("count") { + fmt.Fprintf(runtime.IO().ErrOut, + "note: --dimension/--count is superseded by --rows/--cols, which state both axes at once; this call is equivalent to %s\n", + dimFreezeEquivalent(runtime)) + } out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input) if err != nil { return err @@ -481,33 +502,88 @@ var DimFreeze = common.Shortcut{ }, } +// dimFreezeEquivalent renders the --rows/--cols spelling of a legacy +// --dimension/--count call, so the deprecation note carries the exact +// replacement instead of a generic pointer. +func dimFreezeEquivalent(runtime flagView) string { + count := runtime.Int("count") + if count == 0 { + return "--rows 0 --cols 0" + } + if runtime.Str("dimension") == "row" { + return fmt.Sprintf("--rows %d", count) + } + return fmt.Sprintf("--cols %d", count) +} + +// dimFreezeInput builds the freeze body for both the standalone shortcut and +// the +batch-update sub-op, so the two stay byte-identical (see +// TestBatchOp_BodyMatchesStandalone). +// +// Two request forms, deliberately not mixable: +// +// - --rows / --cols state the complete target state in ONE operation. This +// is the only form that can hold both axes, because freeze is full-state +// replacement server-side (verified 07-31 live: freeze rows=1 then +// columns=2 in two calls ends at 0 rows / 2 columns — the second call +// drops the first axis). It is also the only form usable inside +// +batch-update, whose sub-ops are a static array that cannot read the +// current state to preserve an axis. +// - --dimension + --count is the original single-axis form, kept for +// compatibility. It necessarily unfreezes the axis it does not name. func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err } - if !runtime.Changed("dimension") { - return nil, sheetsValidationForFlag("dimension", "--dimension is required") + pairForm := runtime.Changed("dimension") || runtime.Changed("count") + axisForm := runtime.Changed("rows") || runtime.Changed("cols") + switch { + case pairForm && axisForm: + return nil, sheetsValidationForFlag("rows", + "give either --rows/--cols or --dimension/--count, not both — they are two ways to say the same thing; --rows/--cols is the one that can hold both axes at once") + case !pairForm && !axisForm: + return nil, sheetsValidationForFlag("rows", + "nothing to freeze: pass --rows N and/or --cols N (e.g. --rows 1 --cols 2 holds the header row and the first 2 columns), or the single-axis --dimension row --count 1") } - if !runtime.Changed("count") { - return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)") - } - if runtime.Int("count") < 0 { - return nil, sheetsValidationForFlag("count", "--count must be >= 0") - } - dim := runtime.Str("dimension") - count := runtime.Int("count") - op := "freeze" - if count == 0 { - op = "unfreeze" - } - input := map[string]interface{}{"excel_id": token, "operation": op} - sheetSelectorForToolInput(input, sheetID, sheetName) - if op == "freeze" { - if dim == "row" { - input["freeze_rows"] = count - } else { - input["freeze_columns"] = count + + rows, cols := 0, 0 + if axisForm { + for _, name := range []string{"rows", "cols"} { + if runtime.Changed(name) && runtime.Int(name) < 0 { + return nil, sheetsValidationForFlag(name, "--%s must be >= 0 (0 leaves that axis unfrozen)", name) + } } + rows, cols = runtime.Int("rows"), runtime.Int("cols") + } else { + if !runtime.Changed("dimension") { + return nil, sheetsValidationForFlag("dimension", "--dimension is required alongside --count (or use --rows/--cols to set both axes at once)") + } + if !runtime.Changed("count") { + return nil, sheetsValidationForFlag("count", "--count is required alongside --dimension (0 unfreezes)") + } + if runtime.Int("count") < 0 { + return nil, sheetsValidationForFlag("count", "--count must be >= 0") + } + if runtime.Str("dimension") == "row" { + rows = runtime.Int("count") + } else { + cols = runtime.Int("count") + } + } + + // An all-zero target is the bare "unfreeze" operation, which carries no + // dimension and clears everything — the same request the old --count 0 + // always sent. + input := map[string]interface{}{"excel_id": token, "operation": "unfreeze"} + if rows > 0 || cols > 0 { + input["operation"] = "freeze" + } + sheetSelectorForToolInput(input, sheetID, sheetName) + if rows > 0 { + input["freeze_rows"] = rows + } + if cols > 0 { + input["freeze_columns"] = cols } return input, nil } diff --git a/shortcuts/sheets/lark_sheet_sheet_structure_test.go b/shortcuts/sheets/lark_sheet_sheet_structure_test.go index e5a5b67fb..58da9a597 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure_test.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure_test.go @@ -4,6 +4,7 @@ package sheets import ( + "reflect" "strings" "testing" @@ -135,6 +136,47 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) { "sheet_id": testSheetID, }, }, + { + // The whole point of --rows/--cols: both axes in ONE operation. + // Two single-axis calls would leave only the last axis frozen, + // because freeze is full-state replacement server-side. + name: "+dim-freeze --rows 1 --cols 2 → one combined op", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "1", "--cols", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "freeze", + "sheet_id": testSheetID, + "freeze_rows": float64(1), + "freeze_columns": float64(2), + }, + }, + { + // Stating the survivor is how you unfreeze one axis and keep the + // other; a zero axis is simply omitted from the body. + name: "+dim-freeze --rows 0 --cols 2 → columns only", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "2"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "freeze", + "sheet_id": testSheetID, + "freeze_columns": float64(2), + }, + }, + { + name: "+dim-freeze --rows 0 --cols 0 → unfreeze", + sc: DimFreeze, + args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "0"}, + toolName: "modify_sheet_structure", + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation": "unfreeze", + "sheet_id": testSheetID, + }, + }, { name: "+dim-group row 1:5 fold", sc: DimGroup, @@ -293,6 +335,103 @@ func TestDimRange_Validation(t *testing.T) { } } +// TestDimFreezeEquivalent pins the replacement spelling printed by the +// phase-1 deprecation note: it must be the exact --rows/--cols call the user +// should switch to, not a generic pointer. Each pairing is also asserted for +// body equality, which is what makes the legacy form strictly redundant. +func TestDimFreezeEquivalent(t *testing.T) { + t.Parallel() + cases := []struct { + dimension string + count int + want string + }{ + {"row", 2, "--rows 2"}, + {"column", 3, "--cols 3"}, + {"row", 0, "--rows 0 --cols 0"}, + {"column", 0, "--rows 0 --cols 0"}, + } + for _, tt := range cases { + t.Run(tt.want, func(t *testing.T) { + t.Parallel() + legacy := newMapFlagViewForCommand("+dim-freeze", map[string]interface{}{ + "dimension": tt.dimension, "count": tt.count, + }) + if got := dimFreezeEquivalent(legacy); got != tt.want { + t.Fatalf("dimFreezeEquivalent = %q, want %q", got, tt.want) + } + // The advertised replacement must produce the identical body. + modern := map[string]interface{}{} + if tt.count > 0 { + if tt.dimension == "row" { + modern["rows"] = tt.count + } else { + modern["cols"] = tt.count + } + } else { + modern["rows"], modern["cols"] = 0, 0 + } + legacyInput, err := dimFreezeInput(legacy, testToken, testSheetID, "") + if err != nil { + t.Fatalf("legacy form: %v", err) + } + modernInput, err := dimFreezeInput(newMapFlagViewForCommand("+dim-freeze", modern), testToken, testSheetID, "") + if err != nil { + t.Fatalf("modern form: %v", err) + } + if !reflect.DeepEqual(legacyInput, modernInput) { + t.Fatalf("bodies diverge:\n legacy = %v\n modern = %v", legacyInput, modernInput) + } + }) + } +} + +// TestDimFreeze_FormValidation pins the two request forms as mutually +// exclusive, and pins that neither-form is a prescriptive error rather than a +// silent no-op. +func TestDimFreeze_FormValidation(t *testing.T) { + t.Parallel() + cases := []struct { + name string + args []string + want string + }{ + { + name: "forms cannot be mixed", + args: []string{"--rows", "1", "--dimension", "row", "--count", "1"}, + want: "not both", + }, + { + name: "neither form given", + args: []string{}, + want: "nothing to freeze", + }, + { + name: "negative rows", + args: []string{"--rows", "-1"}, + want: "--rows must be >= 0", + }, + { + name: "count without dimension", + args: []string{"--count", "2"}, + want: "--dimension is required alongside --count", + }, + { + name: "dimension without count", + args: []string{"--dimension", "row"}, + want: "--count is required alongside --dimension", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + args := append([]string{"--url", testURL, "--sheet-id", testSheetID, "--dry-run"}, tt.args...) + _, _, err := runShortcutCapturingErr(t, DimFreeze, args) + requireValidation(t, err, tt.want) + }) + } +} + // TestDimMove_DryRun verifies the native v3 move_dimension payload shape. // 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 diff --git a/skills/lark-sheets/references/lark-sheets-sheet-structure.md b/skills/lark-sheets/references/lark-sheets-sheet-structure.md index 7aa95999d..8334f47e1 100644 --- a/skills/lark-sheets/references/lark-sheets-sheet-structure.md +++ b/skills/lark-sheets/references/lark-sheets-sheet-structure.md @@ -111,8 +111,8 @@ _公共四件套 · 系统:`--dry-run`_ | Flag | Type | 必填 | 说明 | | --- | --- | --- | --- | -| `--dimension` | string | required | 维度方向(行或列)(可选值:`row` / `column`) | -| `--count` | int | required | 冻结前 N 行/列;传 0 解除冻结 | +| `--rows` | int | optional | 冻结前 N 行;与 --cols 一起描述完整冻结状态,省略的轴即为不冻结(0 表示不冻结行) | +| `--cols` | int | optional | 冻结前 N 列;与 --rows 一起描述完整冻结状态,省略的轴即为不冻结(0 表示不冻结列) | ### `+dim-group` @@ -202,9 +202,14 @@ lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --t ### `+dim-freeze` +冻结是**整份状态覆盖**、不是按轴叠加:`--rows` / `--cols` 一起描述完整的目标状态,没写的轴即为不冻结。所以要同时冻住行和列必须一次给全,拆成两次调用只会剩下最后一次的那个轴。 + ```bash -# 冻结前 1 行(--count 传 0 解除冻结) -lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --count 1 +# 冻结前 1 行 + 前 2 列(一次给全) +lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --rows 1 --cols 2 + +# 解除行冻结但保住列:把要保留的轴一并写出 +lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --rows 0 --cols 2 ``` ### `+dim-group` / `+dim-ungroup`(大纲) @@ -213,6 +218,6 @@ lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --dimension row --coun ### Validate / DryRun / Execute 约束 -- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`,`--range` 与 `--ranges` 二选一、`--ranges` 各区间同维度且不可重叠(≤100 个);`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。 +- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-freeze` 至少给 `--rows` / `--cols` 之一;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`,`--range` 与 `--ranges` 二选一、`--ranges` 各区间同维度且不可重叠(≤100 个);`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `lark-sheets-range-operations.md`。 - `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。 - `Execute`:写后不自动回读;如需确认,自行调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen` 查看受影响的范围。