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.
This commit is contained in:
xiongyuanwen-byted
2026-07-31 18:41:23 +08:00
parent 13d4350557
commit 3624499bcb
6 changed files with 283 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@@ -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 <URL> --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)",
"Example: lark-cli sheets +dim-freeze --url <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
}

View File

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

View File

@@ -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` 查看受影响的范围。