From 0ea7c14e4a9ceae460a58dfce7ef71528c662b72 Mon Sep 17 00:00:00 2001 From: xiongyuanwen-byted Date: Thu, 21 May 2026 10:41:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(sheets):=20make=20+batch-update=20sub-ops?= =?UTF-8?q?=20reuse=20standalone=20flag=E2=86=92body=20translators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-ops previously near-passed-through their input, so any shortcut whose standalone translator renames fields broke inside a batch: +range-copy lost range/destination_range (transform_range errored "range missing") and +rows-resize lost range/resize_height ("No resize operation specified"). Introduce a flagView interface (satisfied by *common.RuntimeContext) and a map-backed mapFlagView, then route every batchable sub-op through the SAME *Input builder the standalone shortcut uses. mapFlagView seeds flag-defs.json defaults for value reads while keeping Changed() user-driven, so a sub-op body is byte-identical to the standalone body — locked by a batch-vs-standalone contract test over all ~40 batchable shortcuts. Also fix single-row/column resize: start==end now formats as "23:23" / "C:C" (resize_range rejects a bare "23"); dimRangeFull keeps both sides while dimRange's collapse stays for modify_sheet_structure consumers. --- shortcuts/sheets/batch_op_contract_test.go | 355 ++++++++++++++++++ shortcuts/sheets/batch_op_dispatch.go | 265 ++++++++----- shortcuts/sheets/flag_view.go | 247 ++++++++++++ shortcuts/sheets/helpers.go | 10 +- .../sheets/lark_sheet_batch_update_test.go | 6 +- shortcuts/sheets/lark_sheet_object_crud.go | 24 +- .../sheets/lark_sheet_range_operations.go | 20 +- .../lark_sheet_range_operations_test.go | 4 +- shortcuts/sheets/lark_sheet_search_replace.go | 4 +- .../sheets/lark_sheet_sheet_structure.go | 19 +- shortcuts/sheets/lark_sheet_workbook.go | 88 ++--- shortcuts/sheets/lark_sheet_write_cells.go | 10 +- .../references/lark-sheets-batch-update.md | 16 + 13 files changed, 892 insertions(+), 176 deletions(-) create mode 100644 shortcuts/sheets/batch_op_contract_test.go create mode 100644 shortcuts/sheets/flag_view.go diff --git a/shortcuts/sheets/batch_op_contract_test.go b/shortcuts/sheets/batch_op_contract_test.go new file mode 100644 index 000000000..7f420deb5 --- /dev/null +++ b/shortcuts/sheets/batch_op_contract_test.go @@ -0,0 +1,355 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable +// shortcut, the MCP body produced inside +batch-update must be byte-for-byte +// identical to the body the same shortcut produces when invoked standalone +// (both observed via --dry-run, comparing tool_name + decoded input). This is +// what guarantees "a sub-op behaves exactly like the standalone command", and +// it is the regression guard for the whole flag→body translator reuse. +// +// Each case provides the standalone CLI args and the equivalent sub-op input +// object (same CLI flag names, minus the spreadsheet locator which the batch +// supplies at the top level). +func TestBatchOp_BodyMatchesStandalone(t *testing.T) { + t.Parallel() + + cases := []struct { + shortcut string + sc common.Shortcut + // standalone args (excluding --url, which every case shares) + args []string + // sub-op input object as JSON (CLI flag names; no excel_id/url) + subInput string + }{ + { + shortcut: "+cells-set", + sc: CellsSet, + args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`}, + subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`, + }, + { + shortcut: "+cells-clear", + sc: CellsClear, + args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"}, + subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`, + }, + { + shortcut: "+cells-replace", + sc: CellsReplace, + args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"}, + subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`, + }, + { + shortcut: "+csv-put", + sc: CsvPut, + args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"}, + subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`, + }, + { + shortcut: "+cells-merge", + sc: CellsMerge, + args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"}, + subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`, + }, + { + shortcut: "+cells-unmerge", + sc: CellsUnmerge, + args: []string{"--sheet-id", "sh1", "--range", "A1:C1"}, + subInput: `{"sheet-id":"sh1","range":"A1:C1"}`, + }, + { + 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"}`, + }, + { + 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}`, + }, + { + 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}`, + }, + { + shortcut: "+dim-freeze", + sc: DimFreeze, + args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"}, + subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`, + }, + { + 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"}`, + }, + { + 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}`, + }, + { + 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"}`, + }, + { + shortcut: "+range-move", + sc: RangeMove, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`, + }, + { + shortcut: "+range-copy", + sc: RangeCopy, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`, + }, + { + shortcut: "+range-fill", + sc: RangeFill, + args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"}, + subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`, + }, + { + shortcut: "+range-sort", + sc: RangeSort, + args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"col":"B","order":"asc"}]`, "--has-header"}, + subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"col":"B","order":"asc"}],"has-header":true}`, + }, + { + shortcut: "+sheet-create", + sc: SheetCreate, + args: []string{"--title", "New", "--index", "2"}, + subInput: `{"title":"New","index":2}`, + }, + { + shortcut: "+sheet-delete", + sc: SheetDelete, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-rename", + sc: SheetRename, + args: []string{"--sheet-id", "sh1", "--title", "Renamed"}, + subInput: `{"sheet-id":"sh1","title":"Renamed"}`, + }, + { + shortcut: "+sheet-copy", + sc: SheetCopy, + args: []string{"--sheet-id", "sh1", "--title", "Copy"}, + subInput: `{"sheet-id":"sh1","title":"Copy"}`, + }, + { + shortcut: "+sheet-hide", + sc: SheetHide, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-unhide", + sc: SheetUnhide, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+sheet-set-tab-color", + sc: SheetSetTabColor, + args: []string{"--sheet-id", "sh1", "--color", "#FF0000"}, + subInput: `{"sheet-id":"sh1","color":"#FF0000"}`, + }, + { + shortcut: "+dropdown-set", + sc: DropdownSet, + args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"}, + subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`, + }, + { + shortcut: "+chart-create", + sc: ChartCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"start":"A1"}}`}, + subInput: `{"sheet-id":"sh1","properties":{"position":{"start":"A1"}}}`, + }, + { + shortcut: "+chart-update", + sc: ChartUpdate, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"title":"T"}`}, + subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"title":"T"}}`, + }, + { + shortcut: "+chart-delete", + sc: ChartDelete, + args: []string{"--sheet-id", "sh1", "--chart-id", "c1"}, + subInput: `{"sheet-id":"sh1","chart-id":"c1"}`, + }, + { + shortcut: "+pivot-create", + sc: PivotCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"}, + subInput: `{"sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`, + }, + { + shortcut: "+cond-format-create", + sc: CondFormatCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicate", "--ranges", `["A1:A100"]`}, + subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicate","ranges":["A1:A100"]}`, + }, + { + shortcut: "+filter-create", + sc: FilterCreate, + args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`, + }, + { + shortcut: "+filter-update", + sc: FilterUpdate, + args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`, + }, + { + shortcut: "+filter-delete", + sc: FilterDelete, + args: []string{"--sheet-id", "sh1"}, + subInput: `{"sheet-id":"sh1"}`, + }, + { + shortcut: "+filter-view-create", + sc: FilterViewCreate, + args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`}, + subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`, + }, + { + shortcut: "+sparkline-create", + sc: SparklineCreate, + args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`}, + subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`, + }, + { + shortcut: "+sparkline-delete", + sc: SparklineDelete, + args: []string{"--sheet-id", "sh1", "--group-id", "g1"}, + subInput: `{"sheet-id":"sh1","group-id":"g1"}`, + }, + { + shortcut: "+float-image-create", + sc: FloatImageCreate, + args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"}, + subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`, + }, + { + shortcut: "+float-image-delete", + sc: FloatImageDelete, + args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"}, + subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.shortcut, func(t *testing.T) { + t.Parallel() + + mapping, ok := batchOpDispatch[tc.shortcut] + if !ok { + t.Fatalf("%s not in batchOpDispatch", tc.shortcut) + } + + // Standalone body via the shortcut's own dry-run. + standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName) + + // Batch body via the +batch-update translator. + var subInput map[string]interface{} + if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil { + t.Fatalf("bad subInput JSON: %v", err) + } + fv := newMapFlagViewForCommand(tc.shortcut, subInput) + sid := subInput["sheet-id"] + sname := subInput["sheet-name"] + sidStr, _ := sid.(string) + snameStr, _ := sname.(string) + batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr) + if err != nil { + t.Fatalf("batch translate failed: %v", err) + } + + // Round-trip the batch body through JSON so number types match the + // standalone path (which is decoded from a JSON string). + batchBody = jsonRoundTrip(t, batchBody) + + if !reflect.DeepEqual(standaloneBody, batchBody) { + t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody) + } + }) + } +} + +func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two +// originally reported failures: +range-copy and +rows-resize sub-ops must +// translate to the correct MCP body (not a near-passthrough that drops +// required fields). +func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) { + t.Parallel() + + // +range-copy → transform_range with range / destination_range (not the + // raw source_range / target_range that used to leak through). + body := parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`, + "--yes", + }) + ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) + copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" { + t.Errorf("+range-copy sub-op body wrong: %#v", copyIn) + } + if copyIn["operation"] != "copy" { + t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"]) + } + + // +rows-resize → resize_range with range + resize_height (not raw start/end). + body = parseDryRunBody(t, BatchUpdate, []string{ + "--url", testURL, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","start":22,"end":22,"type":"pixel","size":40}}]`, + "--yes", + }) + ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) + resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{}) + if resizeIn["range"] != "23:23" { + t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"]) + } + rh, _ := resizeIn["resize_height"].(map[string]interface{}) + if rh == nil || rh["type"] != "pixel" { + t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn) + } +} diff --git a/shortcuts/sheets/batch_op_dispatch.go b/shortcuts/sheets/batch_op_dispatch.go index 295053c6d..4c2ae3861 100644 --- a/shortcuts/sheets/batch_op_dispatch.go +++ b/shortcuts/sheets/batch_op_dispatch.go @@ -4,6 +4,8 @@ package sheets import ( + "strings" + "github.com/larksuite/cli/shortcuts/common" ) @@ -11,123 +13,208 @@ import ( // // 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}: // -// [{"shortcut": "+dim-insert", "input": {...}}, ...] +// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...] // -// 而底层 MCP batch_update tool 的契约是 {tool_name, input} —— input 里某些 -// MCP tool 还需要 operation 字段区分动作(如 modify_sheet_structure -// 的 insert / delete / hide / unhide / freeze / group / ungroup)。 +// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 / +// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是 +// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同 +// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range)。 // -// translateBatchOp 做这层翻译:查表 shortcut → mcpToolName + 可选 operation, -// 然后把 operation 注入 input.operation。dispatch 表只列**可纳入 atomic -// batch 的 write shortcut**——读操作、fan-out wrapper(包括 +batch-update -// 自身)、走 legacy v2 endpoint 的 shortcut(如 +dim-move)、需要多步副作用 -// 的 shortcut(如 +cells-set-image / +workbook-create)一律不放进表里, +// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator** +// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作 +// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由 +// batch-vs-standalone 契约测试保证)。dispatch 表只列**可纳入 atomic batch +// 的 write shortcut**——读操作、fan-out wrapper(+batch-update 自身、 +// +cells-batch-set-style、+dropdown-{update,delete})一律不放进表里, // 用户传到 +batch-update 里会被 translator 拒绝。 +// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP +// tool body for the underlying batch_update sub-tool. token is the +// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved +// sheet selector for this sub-op. The returned body already carries excel_id +// and (where the tool needs one) the operation discriminator — exactly as the +// standalone shortcut would emit. +type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) + type batchOpMapping struct { // mcpToolName 是底层 MCP batch_update 接受的 tool_name。 mcpToolName string - // operationField 注入到 input.operation 的值;空 = 不注入(MCP tool 没有 - // operation 字段,如 set_cell_range / clear_cell_range / replace_data / - // set_range_from_csv / resize_range)。 - operationField string + // translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。 + translate batchTranslateFn } -// batchOpDispatch 全表 41 项,覆盖 sheet skill 下所有可 batch 的 write shortcut。 -// 增删请同步 canonical-spec/tool-schemas/cli-schemas.json 的 shortcut enum。 +// noErrTranslate adapts a builder that cannot fail into a batchTranslateFn. +func noErrTranslate(f func(fv flagView, token, sheetID, sheetName string) map[string]interface{}) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return f(fv, token, sheetID, sheetName), nil + } +} + +// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object +// CRUD spec to the shared object_crud builders. +func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectCreateInput(fv, token, sheetID, sheetName, spec) + } +} + +func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectUpdateInput(fv, token, sheetID, sheetName, spec) + } +} + +func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn { + return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + return objectDeleteInput(fv, token, sheetID, sheetName, spec), nil + } +} + +// batchOpDispatch covers every write shortcut that can join an atomic batch. var batchOpDispatch = map[string]batchOpMapping{ // ─── 单元格内容 ────────────────────────────────────────────────── - "+cells-set": {mcpToolName: "set_cell_range"}, - "+cells-set-style": {mcpToolName: "set_cell_range"}, - "+cells-clear": {mcpToolName: "clear_cell_range"}, - "+cells-replace": {mcpToolName: "replace_data"}, - "+csv-put": {mcpToolName: "set_range_from_csv"}, - "+dropdown-set": {mcpToolName: "set_cell_range"}, + "+cells-set": {"set_cell_range", cellsSetInput}, + "+cells-set-style": {"set_cell_range", cellsSetStyleInput}, + "+cells-clear": {"clear_cell_range", noErrTranslate(cellsClearInput)}, + "+cells-replace": {"replace_data", noErrTranslate(replaceInput)}, + "+csv-put": {"set_range_from_csv", noErrTranslate(csvPutInput)}, + "+dropdown-set": {"set_cell_range", dropdownSetInput}, // ─── 单元格合并 (merge_cells, operation 区分) ──────────────────── - "+cells-merge": {mcpToolName: "merge_cells", operationField: "merge"}, - "+cells-unmerge": {mcpToolName: "merge_cells", operationField: "unmerge"}, + "+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return mergeInput(fv, token, sid, sname, "merge", true), nil + }}, + "+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return mergeInput(fv, token, sid, sname, "unmerge", false), nil + }}, // ─── 行列结构 (modify_sheet_structure, operation 区分) ────────── - // 注意:+dim-move 不在此 — 单 shortcut 走 legacy v2 dimension_range - // endpoint,不经 MCP,无法 batch。 - // +dim-freeze 静态注入 operation="freeze",单 shortcut 里基于 count==0 - // 切换 unfreeze 的路径在 batch 里不支持(用户要 unfreeze 用单 shortcut)。 - "+dim-insert": {mcpToolName: "modify_sheet_structure", operationField: "insert"}, - "+dim-delete": {mcpToolName: "modify_sheet_structure", operationField: "delete"}, - "+dim-hide": {mcpToolName: "modify_sheet_structure", operationField: "hide"}, - "+dim-unhide": {mcpToolName: "modify_sheet_structure", operationField: "unhide"}, - "+dim-freeze": {mcpToolName: "modify_sheet_structure", operationField: "freeze"}, - "+dim-group": {mcpToolName: "modify_sheet_structure", operationField: "group"}, - "+dim-ungroup": {mcpToolName: "modify_sheet_structure", operationField: "ungroup"}, + "+dim-insert": {"modify_sheet_structure", noErrTranslate(dimInsertInput)}, + "+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "delete"), nil + }}, + "+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "hide"), nil + }}, + "+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimRangeOpInput(fv, token, sid, sname, "unhide"), nil + }}, + "+dim-freeze": {"modify_sheet_structure", noErrTranslate(dimFreezeInput)}, + "+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimGroupInput(fv, token, sid, sname, "group"), nil + }}, + "+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return dimGroupInput(fv, token, sid, sname, "ungroup"), nil + }}, // ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── - // row/column 通过 input.resize_height vs input.resize_width 顶层 key 表达。 - "+rows-resize": {mcpToolName: "resize_range"}, - "+cols-resize": {mcpToolName: "resize_range"}, + "+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return resizeInput(fv, token, sid, sname, "row"), nil + }}, + "+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return resizeInput(fv, token, sid, sname, "column"), nil + }}, // ─── 区域操作 (transform_range, operation 区分) ───────────────── - "+range-move": {mcpToolName: "transform_range", operationField: "move"}, - "+range-copy": {mcpToolName: "transform_range", operationField: "copy"}, - "+range-fill": {mcpToolName: "transform_range", operationField: "fill"}, - "+range-sort": {mcpToolName: "transform_range", operationField: "sort"}, + "+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return transformMoveCopyInput(fv, token, sid, sname, "move", false), nil + }}, + "+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return transformMoveCopyInput(fv, token, sid, sname, "copy", true), nil + }}, + "+range-fill": {"transform_range", noErrTranslate(rangeFillInput)}, + "+range-sort": {"transform_range", rangeSortInput}, // ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ── - "+sheet-create": {mcpToolName: "modify_workbook_structure", operationField: "create"}, - "+sheet-delete": {mcpToolName: "modify_workbook_structure", operationField: "delete"}, - "+sheet-rename": {mcpToolName: "modify_workbook_structure", operationField: "rename"}, - "+sheet-move": {mcpToolName: "modify_workbook_structure", operationField: "move"}, - "+sheet-copy": {mcpToolName: "modify_workbook_structure", operationField: "copy"}, - "+sheet-hide": {mcpToolName: "modify_workbook_structure", operationField: "hide"}, - "+sheet-unhide": {mcpToolName: "modify_workbook_structure", operationField: "unhide"}, - "+sheet-set-tab-color": {mcpToolName: "modify_workbook_structure", operationField: "set_tab_color"}, + "+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) { + return sheetCreateInput(fv, token), nil + }}, + "+sheet-delete": {"modify_workbook_structure", noErrTranslate(sheetDeleteInput)}, + "+sheet-rename": {"modify_workbook_structure", noErrTranslate(sheetRenameInput)}, + "+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput}, + "+sheet-copy": {"modify_workbook_structure", noErrTranslate(sheetCopyInput)}, + "+sheet-hide": {"modify_workbook_structure", noErrTranslate(func(fv flagView, t, sid, sn string) map[string]interface{} { + return sheetVisibilityInput(fv, t, sid, sn, "hide") + })}, + "+sheet-unhide": {"modify_workbook_structure", noErrTranslate(func(fv flagView, t, sid, sn string) map[string]interface{} { + return sheetVisibilityInput(fv, t, sid, sn, "unhide") + })}, + "+sheet-set-tab-color": {"modify_workbook_structure", noErrTranslate(sheetSetTabColorInput)}, // ─── 对象族 CRUD (manage_*_object, operation 区分) ───────────── - "+chart-create": {mcpToolName: "manage_chart_object", operationField: "create"}, - "+chart-update": {mcpToolName: "manage_chart_object", operationField: "update"}, - "+chart-delete": {mcpToolName: "manage_chart_object", operationField: "delete"}, + "+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)}, + "+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)}, + "+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)}, - "+pivot-create": {mcpToolName: "manage_pivot_table_object", operationField: "create"}, - "+pivot-update": {mcpToolName: "manage_pivot_table_object", operationField: "update"}, - "+pivot-delete": {mcpToolName: "manage_pivot_table_object", operationField: "delete"}, + "+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)}, + "+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)}, + "+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)}, - "+cond-format-create": {mcpToolName: "manage_conditional_format_object", operationField: "create"}, - "+cond-format-update": {mcpToolName: "manage_conditional_format_object", operationField: "update"}, - "+cond-format-delete": {mcpToolName: "manage_conditional_format_object", operationField: "delete"}, + "+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)}, + "+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)}, + "+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)}, - "+filter-create": {mcpToolName: "manage_filter_object", operationField: "create"}, - "+filter-update": {mcpToolName: "manage_filter_object", operationField: "update"}, - "+filter-delete": {mcpToolName: "manage_filter_object", operationField: "delete"}, + "+filter-create": {"manage_filter_object", filterCreateInput}, + "+filter-update": {"manage_filter_object", filterUpdateInput}, + "+filter-delete": {"manage_filter_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sid, sname) + return input, nil + }}, - "+filter-view-create": {mcpToolName: "manage_filter_view_object", operationField: "create"}, - "+filter-view-update": {mcpToolName: "manage_filter_view_object", operationField: "update"}, - "+filter-view-delete": {mcpToolName: "manage_filter_view_object", operationField: "delete"}, + "+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)}, + "+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)}, + "+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)}, - "+sparkline-create": {mcpToolName: "manage_sparkline_object", operationField: "create"}, - "+sparkline-update": {mcpToolName: "manage_sparkline_object", operationField: "update"}, - "+sparkline-delete": {mcpToolName: "manage_sparkline_object", operationField: "delete"}, + "+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)}, + "+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)}, + "+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)}, - "+float-image-create": {mcpToolName: "manage_float_image_object", operationField: "create"}, - "+float-image-update": {mcpToolName: "manage_float_image_object", operationField: "update"}, - "+float-image-delete": {mcpToolName: "manage_float_image_object", operationField: "delete"}, + "+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return floatImageWriteInput(fv, token, sid, sname, "create", false) + }}, + "+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { + return floatImageWriteInput(fv, token, sid, sname, "update", true) + }}, + "+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)}, } -// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们要么由 -// shortcut 名隐含(operation),要么由 +batch-update 顶层 --url/--token -// 统一提供(excel_id / spreadsheet_token / url)。 +// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the +// standalone shortcut it cannot issue the get_workbook_structure read that +// auto-derives sheet_id / source_index, so both must be supplied explicitly. +func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { + if sheetID == "" { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)") + } + if !fv.Changed("source-index") { + return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)") + } + return map[string]interface{}{ + "excel_id": token, + "operation": "move", + "sheet_id": sheetID, + "source_index": fv.Int("source-index"), + "target_index": fv.Int("index"), + }, nil +} + +// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由 +// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。 var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"} // translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP -// batch_update 的 {tool_name, input(+operation)}。`index` 用于错误信息定位。 +// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用 +// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone +// translator 翻成 MCP body。 // // 失败场景: // - shortcut 字段缺失 / 非 string -// - shortcut 不在 dispatch 表(典型:拼写错;用户传了 read 操作; -// 用户嵌套 +batch-update / +cells-batch-set-style 之类的 fan-out wrapper) +// - shortcut 不在 dispatch 表(拼写错;read 操作;嵌套 fan-out wrapper) // - input 不是 object // - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch) // - input 里手填了 excel_id / spreadsheet_token / url -func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error) { +// - 子操作的 translator 报错(如缺必填字段) +func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) { op, ok := raw.(map[string]interface{}) if !ok { return nil, common.FlagErrorf("operations[%d] must be a JSON object", index) @@ -144,7 +231,7 @@ func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error if !ok { return nil, common.FlagErrorf( "operations[%d]: shortcut %q not allowed in +batch-update "+ - "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +dropdown-{update,delete} / +dim-move are excluded; "+ + "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +dropdown-{update,delete} are excluded; "+ "run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)", index, sc, ) @@ -181,36 +268,30 @@ func translateBatchOp(raw interface{}, index int) (map[string]interface{}, error return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k) } } - // 浅拷贝 input,注入 operation(如有),再补 excel_id 由调用方统一注入到顶层后, - // translator 也把 excel_id 写进 sub-op input(MCP tool 要求每个 sub-tool 都带)。 - out := make(map[string]interface{}, len(input)+1) - for k, v := range input { - out[k] = v - } - if mapping.operationField != "" { - out["operation"] = mapping.operationField + fv := newMapFlagViewForCommand(sc, input) + sheetID := strings.TrimSpace(fv.Str("sheet-id")) + sheetName := strings.TrimSpace(fv.Str("sheet-name")) + body, err := mapping.translate(fv, token, sheetID, sheetName) + if err != nil { + return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err) } return map[string]interface{}{ "tool_name": mapping.mcpToolName, - "input": out, + "input": body, }, nil } // translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。 -// 翻译后会把 excel_id 注入每个 sub-op 的 input(MCP 契约要求)。 func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { if len(rawOps) == 0 { return nil, common.FlagErrorf("--operations must be a non-empty JSON array") } out := make([]interface{}, 0, len(rawOps)) for i, raw := range rawOps { - translated, err := translateBatchOp(raw, i) + translated, err := translateBatchOp(raw, token, i) if err != nil { return nil, err } - // MCP batch_update 每个 sub-tool 的 input 都需要 excel_id(与单调用一致)。 - input := translated["input"].(map[string]interface{}) - input["excel_id"] = token out = append(out, translated) } return out, nil diff --git a/shortcuts/sheets/flag_view.go b/shortcuts/sheets/flag_view.go new file mode 100644 index 000000000..d99f65304 --- /dev/null +++ b/shortcuts/sheets/flag_view.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "encoding/json" + "fmt" + "strings" +) + +// flagView is the read-only flag-accessor surface that every CLI-shape → +// MCP-tool-body translator (the *Input builders) depends on. It is satisfied +// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut +// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops). +// +// Routing both paths through the same interface lets a sub-op inside +// +batch-update reuse the exact same translator the standalone shortcut runs, +// so the generated MCP body is identical either way (enforced by the +// batch-vs-standalone contract test). +type flagView interface { + Str(name string) string + Int(name string) int + Int64(name string) int64 + Float64(name string) float64 + Bool(name string) bool + StrArray(name string) []string + StrSlice(name string) []string + Changed(name string) bool +} + +// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the +// flagView interface so the standalone *Input translators can consume it. +// +// Keys are matched leniently against the CLI flag name: a translator asking for +// "source-range" finds either "source-range" or "source_range" in the map (the +// reference docs use CLI flag names; users frequently send the underscore +// form). Composite values (arrays / objects for flags like cells / properties / +// sort-keys) are re-encoded to a JSON string on Str() so the downstream +// parseJSONFlag round-trips them exactly as it would a CLI string argument. +// +// To mirror the standalone cobra layer exactly, value reads fall back to the +// flag's declared default (seeded from flag-defs.json), while Changed() reflects +// only what the user actually provided. This split matters because some +// translators branch on Changed() (e.g. omit target_index unless --index was +// set) and others read defaulted values (e.g. row-count defaults to 200). +type mapFlagView struct { + raw map[string]interface{} // user-supplied sub-op input (drives Changed) + defaults map[string]interface{} // flag defaults (value fallback only) +} + +// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback +// defaults declared for `command` in flag-defs.json, so an absent flag resolves +// to the same value the standalone cobra command would carry. +func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView { + fv := mapFlagView{raw: input, defaults: map[string]interface{}{}} + defs, err := loadFlagDefs() + if err != nil { + return fv + } + spec, ok := defs[command] + if !ok { + return fv + } + for _, df := range spec.Flags { + if df.Kind == "system" || df.Default == "" { + continue + } + fv.defaults[df.Name] = typedDefault(df) + } + return fv +} + +// typedDefault converts a flag's string default to the Go type matching its +// declared kind, so Int()/Bool()/Float64() see the right type. +func typedDefault(df flagDef) interface{} { + switch df.Type { + case "bool": + return df.Default == "true" + case "int": + var n int + fmt.Sscanf(df.Default, "%d", &n) + return n + case "int64": + var n int64 + fmt.Sscanf(df.Default, "%d", &n) + return n + case "float64": + var f float64 + fmt.Sscanf(df.Default, "%g", &f) + return f + default: + return df.Default + } +} + +// lookup resolves a flag name for a VALUE read: user input first (hyphen↔ +// underscore tolerant), then the seeded default. Returns the value and whether +// it was found in either source. +func (m mapFlagView) lookup(name string) (interface{}, bool) { + if v, ok := m.lookupRaw(name); ok { + return v, true + } + if m.defaults != nil { + if v, ok := m.defaults[name]; ok { + return v, true + } + } + return nil, false +} + +// lookupRaw resolves a flag name against the user-supplied input only, trying +// the exact key then the hyphen↔underscore variants. +func (m mapFlagView) lookupRaw(name string) (interface{}, bool) { + if v, ok := m.raw[name]; ok { + return v, true + } + if alt := strings.ReplaceAll(name, "-", "_"); alt != name { + if v, ok := m.raw[alt]; ok { + return v, true + } + } + if alt := strings.ReplaceAll(name, "_", "-"); alt != name { + if v, ok := m.raw[alt]; ok { + return v, true + } + } + return nil, false +} + +func (m mapFlagView) Str(name string) string { + v, ok := m.lookup(name) + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + case bool, float64, int, int64: + b, _ := json.Marshal(t) + return string(b) + default: + // Arrays / objects (cells, properties, sort-keys, options, ...) are + // re-encoded so the translator's parseJSONFlag re-parses them. + b, err := json.Marshal(t) + if err != nil { + return "" + } + return string(b) + } +} + +func (m mapFlagView) Int(name string) int { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case int64: + return int(t) + } + return 0 +} + +func (m mapFlagView) Int64(name string) int64 { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return int64(t) + case int: + return int64(t) + case int64: + return t + } + return 0 +} + +func (m mapFlagView) Float64(name string) float64 { + v, ok := m.lookup(name) + if !ok { + return 0 + } + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case int64: + return float64(t) + } + return 0 +} + +func (m mapFlagView) Bool(name string) bool { + v, ok := m.lookup(name) + if !ok { + return false + } + b, _ := v.(bool) + return b +} + +func (m mapFlagView) StrArray(name string) []string { + return m.strSliceLike(name) +} + +func (m mapFlagView) StrSlice(name string) []string { + return m.strSliceLike(name) +} + +func (m mapFlagView) strSliceLike(name string) []string { + v, ok := m.lookup(name) + if !ok || v == nil { + return nil + } + switch t := v.(type) { + case []string: + return t + case []interface{}: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + case string: + // CSV / comma-separated (matches cobra StringSlice behavior). + if t == "" { + return nil + } + return strings.Split(t, ",") + } + return nil +} + +func (m mapFlagView) Changed(name string) bool { + _, ok := m.lookupRaw(name) + return ok +} diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 53e53be95..e52a2b0f5 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -103,7 +103,7 @@ func sheetSelectorPlaceholder(sheetID, sheetName string) string { // parseJSONFlag parses a JSON string from a flag value. Returns nil when the // flag is empty (caller decides if that's acceptable). Used by --data / // --style / --options / --ranges / --colors and friends. -func parseJSONFlag(runtime *common.RuntimeContext, name string) (interface{}, error) { +func parseJSONFlag(runtime flagView, name string) (interface{}, error) { raw := strings.TrimSpace(runtime.Str(name)) if raw == "" { return nil, nil @@ -116,7 +116,7 @@ func parseJSONFlag(runtime *common.RuntimeContext, name string) (interface{}, er } // requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}. -func requireJSONObject(runtime *common.RuntimeContext, name string) (map[string]interface{}, error) { +func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) { v, err := parseJSONFlag(runtime, name) if err != nil { return nil, err @@ -132,7 +132,7 @@ func requireJSONObject(runtime *common.RuntimeContext, name string) (map[string] } // requireJSONArray is parseJSONFlag + a type assertion to []interface{}. -func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{}, error) { +func requireJSONArray(runtime flagView, name string) ([]interface{}, error) { v, err := parseJSONFlag(runtime, name) if err != nil { return nil, err @@ -152,7 +152,7 @@ func requireJSONArray(runtime *common.RuntimeContext, name string) ([]interface{ // buildCellStyleFromFlags reads the 11 flat style flags and returns the // cell_styles map expected by set_cell_range. Skips any flag the user // didn't set so partial styles work. -func buildCellStyleFromFlags(runtime *common.RuntimeContext) map[string]interface{} { +func buildCellStyleFromFlags(runtime flagView) map[string]interface{} { style := map[string]interface{}{} if v := runtime.Str("background-color"); v != "" { style["background_color"] = v @@ -189,7 +189,7 @@ func buildCellStyleFromFlags(runtime *common.RuntimeContext) map[string]interfac // borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/ // left/right with style sub-objects). Returns nil when the flag is empty. -func borderStylesFromFlag(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) { if runtime.Str("border-styles") == "" { return nil, nil } diff --git a/shortcuts/sheets/lark_sheet_batch_update_test.go b/shortcuts/sheets/lark_sheet_batch_update_test.go index dbc19200d..23937f428 100644 --- a/shortcuts/sheets/lark_sheet_batch_update_test.go +++ b/shortcuts/sheets/lark_sheet_batch_update_test.go @@ -20,7 +20,7 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) { "--url", testURL, "--operations", `[ {"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}}, - {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","range":"1:3"}} + {"shortcut":"+dim-insert","input":{"sheet_id":"sh1","dimension":"row","start":0,"end":3}} ]`, "--continue-on-error", "--yes", @@ -308,7 +308,7 @@ func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","freeze_rows":2}}]`, + "--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","dimension":"row","count":2}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") @@ -329,7 +329,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) { t.Parallel() body := parseDryRunBody(t, BatchUpdate, []string{ "--url", testURL, - "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","resize_height":{"type":"pixel","value":30}}}]`, + "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","start":0,"end":2,"type":"pixel","size":30}}]`, "--yes", }) input := decodeToolInput(t, body, "batch_update") diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 4457188b2..2ff7c41be 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -42,8 +42,8 @@ type objectCRUDSpec struct { // shortcut-specific flat flags into the input (typically into the // properties map). The callback is responsible for navigating to the // right nesting level. - enhanceCreateInput func(rt *common.RuntimeContext, input map[string]interface{}) - enhanceUpdateInput func(rt *common.RuntimeContext, input map[string]interface{}) + enhanceCreateInput func(rt flagView, input map[string]interface{}) + enhanceUpdateInput func(rt flagView, input map[string]interface{}) } func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { @@ -96,7 +96,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { +func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -166,7 +166,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { +func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) { props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err @@ -233,7 +233,7 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut { } } -func objectDeleteInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectCRUDSpec) map[string]interface{} { +func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": "delete", @@ -265,7 +265,7 @@ var pivotSpec = objectCRUDSpec{ toolName: "manage_pivot_table_object", idFlag: "pivot-table-id", idField: "pivot_table_id", - enhanceCreateInput: func(rt *common.RuntimeContext, input map[string]interface{}) { + enhanceCreateInput: func(rt flagView, input map[string]interface{}) { if v := strings.TrimSpace(rt.Str("target-sheet-id")); v != "" { input["target_sheet_id"] = v } @@ -291,7 +291,7 @@ var PivotDelete = newObjectDeleteShortcut(pivotSpec) // conditional format — CLI surface uses --rule-id (short), wired to the // tool's conditional_format_id on the wire. --rule-type and --ranges are // hoisted out of properties (both required, set on every CRUD write). -var condFormatEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { +var condFormatEnhance = func(rt flagView, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { return @@ -342,7 +342,7 @@ var SparklineDelete = newObjectDeleteShortcut(sparklineSpec) // 10 flat flags. Caller is responsible for marking required flags via // cobra Required:true; this function only enforces the image_token XOR // image_uri pair (one must be set). -func floatImageProperties(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func floatImageProperties(runtime flagView) (map[string]interface{}, error) { token := strings.TrimSpace(runtime.Str("image-token")) uri := strings.TrimSpace(runtime.Str("image-uri")) if token == "" && uri == "" { @@ -440,7 +440,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH } } -func floatImageWriteInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { +func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool) (map[string]interface{}, error) { props, err := floatImageProperties(runtime) if err != nil { return nil, err @@ -482,7 +482,7 @@ var FloatImageDelete = newObjectDeleteShortcut(floatImageDeleteSpec) // it dispatches via the same One-OpenAPI endpoint as every other shortcut. // --view-name and --range are hoisted out of properties (optional on both // create and update; they always win over properties.{view_name, range}). -var filterViewEnhance = func(rt *common.RuntimeContext, input map[string]interface{}) { +var filterViewEnhance = func(rt flagView, input map[string]interface{}) { props, _ := input["properties"].(map[string]interface{}) if props == nil { return @@ -570,7 +570,7 @@ var FilterCreate = common.Shortcut{ }, } -func filterCreateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { props := map[string]interface{}{ "range": strings.TrimSpace(runtime.Str("range")), } @@ -648,7 +648,7 @@ var FilterUpdate = common.Shortcut{ }, } -func filterUpdateInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { props, err := requireJSONObject(runtime, "properties") if err != nil { return nil, err diff --git a/shortcuts/sheets/lark_sheet_range_operations.go b/shortcuts/sheets/lark_sheet_range_operations.go index 2910712a1..a4738e942 100644 --- a/shortcuts/sheets/lark_sheet_range_operations.go +++ b/shortcuts/sheets/lark_sheet_range_operations.go @@ -74,7 +74,7 @@ var CellsClear = common.Shortcut{ }, } -func cellsClearInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func cellsClearInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { scope := runtime.Str("scope") clearType := "contents" switch scope { @@ -149,7 +149,7 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short } } -func mergeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withMergeType bool) map[string]interface{} { +func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "range": strings.TrimSpace(runtime.Str("range")), @@ -293,10 +293,12 @@ func autoSuffix(dimension string) string { } // resizeInput builds the resize_range tool input. dimension is "row" / -// "column"; --end is inclusive on the CLI surface, dimRange wants -// exclusive end, so it is bumped by one here. -func resizeInput(runtime *common.RuntimeContext, token, sheetID, sheetName, dimension string) map[string]interface{} { - rangeStr := dimRange(dimension, runtime.Int("start"), runtime.Int("end")+1) +// "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". +func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) map[string]interface{} { + rangeStr := dimRangeFull(dimension, runtime.Int("start"), runtime.Int("end")+1) input := map[string]interface{}{ "excel_id": token, "range": rangeStr, @@ -504,7 +506,7 @@ func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, } } -func transformMoveCopyInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string, withPasteType bool) map[string]interface{} { +func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": op, @@ -537,7 +539,7 @@ func pasteTypeToTool(pt string) string { return "all" } -func rangeFillInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func rangeFillInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": "fill", @@ -560,7 +562,7 @@ func fillSeriesToToolType(seriesType string) string { return "fillSeries" } -func rangeSortInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func rangeSortInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { keys, err := requireJSONArray(runtime, "sort-keys") if err != nil { return nil, err diff --git a/shortcuts/sheets/lark_sheet_range_operations_test.go b/shortcuts/sheets/lark_sheet_range_operations_test.go index 11bdb1c7e..73930f697 100644 --- a/shortcuts/sheets/lark_sheet_range_operations_test.go +++ b/shortcuts/sheets/lark_sheet_range_operations_test.go @@ -82,12 +82,12 @@ func TestRangeOperationsShortcuts_DryRun(t *testing.T) { }, }, { - name: "+rows-resize --type auto omits --size", + name: "+rows-resize single row (start==end) keeps N:N range", sc: RowsResize, args: []string{"--url", testURL, "--sheet-id", testSheetID, "--start", "0", "--end", "0", "--type", "auto"}, toolName: "resize_range", wantInput: map[string]interface{}{ - "range": "1", + "range": "1:1", "resize_height": map[string]interface{}{"type": "auto"}, }, }, diff --git a/shortcuts/sheets/lark_sheet_search_replace.go b/shortcuts/sheets/lark_sheet_search_replace.go index 3c6f79ebd..680cdc770 100644 --- a/shortcuts/sheets/lark_sheet_search_replace.go +++ b/shortcuts/sheets/lark_sheet_search_replace.go @@ -86,7 +86,7 @@ func searchInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin // searchReplaceOptions packs the four shared boolean flags into the tool's // `options` sub-object. Empty result → caller should omit the field. -func searchReplaceOptions(runtime *common.RuntimeContext) map[string]interface{} { +func searchReplaceOptions(runtime flagView) map[string]interface{} { opts := map[string]interface{}{} if runtime.Bool("match-case") { opts["match_case"] = true @@ -155,7 +155,7 @@ var CellsReplace = common.Shortcut{ }, } -func replaceInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func replaceInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "search_term": runtime.Str("find"), diff --git a/shortcuts/sheets/lark_sheet_sheet_structure.go b/shortcuts/sheets/lark_sheet_sheet_structure.go index 2b10f0819..14a4e9acb 100644 --- a/shortcuts/sheets/lark_sheet_sheet_structure.go +++ b/shortcuts/sheets/lark_sheet_sheet_structure.go @@ -148,7 +148,7 @@ var DimInsert = common.Shortcut{ }, } -func dimInsertInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func dimInsertInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { dim := runtime.Str("dimension") start := runtime.Int("start") end := runtime.Int("end") @@ -273,7 +273,7 @@ var DimFreeze = common.Shortcut{ }, } -func dimFreezeInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { dim := runtime.Str("dimension") count := runtime.Int("count") op := "freeze" @@ -320,7 +320,7 @@ func validateDimRange(ctx context.Context, runtime *common.RuntimeContext) error // dimRangeOpInput builds the tool input for delete/hide/unhide which all // take a `range` field. dimRange handles 0-based exclusive → 1-based inclusive. -func dimRangeOpInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string) map[string]interface{} { +func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": op, @@ -405,7 +405,7 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut { } } -func dimGroupInput(runtime *common.RuntimeContext, token, sheetID, sheetName, op string) map[string]interface{} { +func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { input := dimRangeOpInput(runtime, token, sheetID, sheetName, op) if op == "group" { if gs := runtime.Str("group-state"); gs != "" { @@ -435,6 +435,17 @@ func dimRange(dimension string, start, end int) string { 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) + } + 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 { diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 17fd39e72..633096749 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -122,7 +122,7 @@ var SheetCreate = common.Shortcut{ }, } -func sheetCreateInput(runtime *common.RuntimeContext, token string) map[string]interface{} { +func sheetCreateInput(runtime flagView, token string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "operation": "create", @@ -140,6 +140,42 @@ func sheetCreateInput(runtime *common.RuntimeContext, token string) map[string]i return input } +// sheetDeleteInput / sheetRenameInput / sheetVisibilityInput / +// sheetSetTabColorInput build the modify_workbook_structure body for the +// matching shortcut. Shared by standalone DryRun/Execute and by the +// +batch-update sub-op dispatch so both paths emit an identical body. +func sheetDeleteInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token, "operation": "delete"} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +func sheetRenameInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "rename", + "new_name": strings.TrimSpace(runtime.Str("title")), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +func sheetVisibilityInput(runtime flagView, token, sheetID, sheetName, op string) map[string]interface{} { + input := map[string]interface{}{"excel_id": token, "operation": op} + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + +func sheetSetTabColorInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { + input := map[string]interface{}{ + "excel_id": token, + "operation": "set_tab_color", + "tab_color": runtime.Str("color"), + } + sheetSelectorForToolInput(input, sheetID, sheetName) + return input +} + // SheetDelete deletes a sub-sheet. high-risk-write — framework rejects // without --yes. Always preview with --dry-run first to confirm the target. var SheetDelete = common.Shortcut{ @@ -161,9 +197,7 @@ var SheetDelete = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetDeleteInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -174,9 +208,7 @@ var SheetDelete = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{"excel_id": token, "operation": "delete"} - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetDeleteInput(runtime, token, sheetID, sheetName)) if err != nil { return err } @@ -213,13 +245,7 @@ var SheetRename = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{ - "excel_id": token, - "operation": "rename", - "new_name": strings.TrimSpace(runtime.Str("title")), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetRenameInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -230,13 +256,7 @@ var SheetRename = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{ - "excel_id": token, - "operation": "rename", - "new_name": strings.TrimSpace(runtime.Str("title")), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetRenameInput(runtime, token, sheetID, sheetName)) if err != nil { return err } @@ -388,7 +408,7 @@ var SheetCopy = common.Shortcut{ }, } -func sheetCopyInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func sheetCopyInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{"excel_id": token, "operation": "duplicate"} sheetSelectorForToolInput(input, sheetID, sheetName) if t := strings.TrimSpace(runtime.Str("title")); t != "" { @@ -430,9 +450,7 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{"excel_id": token, "operation": op} - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetVisibilityInput(runtime, token, sheetID, sheetName, op)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -443,9 +461,7 @@ func newSheetVisibilityShortcut(command, desc, op string) common.Shortcut { if err != nil { return err } - input := map[string]interface{}{"excel_id": token, "operation": op} - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetVisibilityInput(runtime, token, sheetID, sheetName, op)) if err != nil { return err } @@ -480,13 +496,7 @@ var SheetSetTabColor = common.Shortcut{ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) - input := map[string]interface{}{ - "excel_id": token, - "operation": "set_tab_color", - "tab_color": runtime.Str("color"), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", input) + return invokeToolDryRun(token, ToolKindWrite, "modify_workbook_structure", sheetSetTabColorInput(runtime, token, sheetID, sheetName)) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) @@ -497,13 +507,7 @@ var SheetSetTabColor = common.Shortcut{ if err != nil { return err } - input := map[string]interface{}{ - "excel_id": token, - "operation": "set_tab_color", - "tab_color": runtime.Str("color"), - } - sheetSelectorForToolInput(input, sheetID, sheetName) - out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input) + out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", sheetSetTabColorInput(runtime, token, sheetID, sheetName)) if err != nil { return err } diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 527d6d5f8..e2478480b 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -86,7 +86,7 @@ var CellsSet = common.Shortcut{ }, } -func cellsSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { cells, err := requireJSONArray(runtime, "cells") if err != nil { return nil, err @@ -168,7 +168,7 @@ var CellsSetStyle = common.Shortcut{ }, } -func cellsSetStyleInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { rangeStr := strings.TrimSpace(runtime.Str("range")) rows, cols, err := rangeDimensions(rangeStr) if err != nil { @@ -256,7 +256,7 @@ var CsvPut = common.Shortcut{ }, } -func csvPutInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} { +func csvPutInput(runtime flagView, token, sheetID, sheetName string) map[string]interface{} { input := map[string]interface{}{ "excel_id": token, "csv": runtime.Str("csv"), @@ -332,7 +332,7 @@ var DropdownSet = common.Shortcut{ }, } -func dropdownSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) (map[string]interface{}, error) { +func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { validation, err := buildDropdownValidation(runtime) if err != nil { return nil, err @@ -361,7 +361,7 @@ func dropdownSetInput(runtime *common.RuntimeContext, token, sheetID, sheetName // buildDropdownValidation packs --options / --colors / --multiple / --highlight // into the data_validation block expected by set_cell_range. -func buildDropdownValidation(runtime *common.RuntimeContext) (map[string]interface{}, error) { +func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) { options, err := requireJSONArray(runtime, "options") if err != nil { return nil, err diff --git a/skills/lark-sheets/references/lark-sheets-batch-update.md b/skills/lark-sheets/references/lark-sheets-batch-update.md index 890215f74..64d4ce936 100644 --- a/skills/lark-sheets/references/lark-sheets-batch-update.md +++ b/skills/lark-sheets/references/lark-sheets-batch-update.md @@ -127,6 +127,22 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" -- # ] ``` +> **子操作 `input` 用该 shortcut 的 CLI flag 名**(连字符 / 下划线均可),与单独调用完全一致;CLI 会用同一套翻译逻辑生成底层 tool body。不要传底层 MCP 字段名。例如: +> - `+range-copy` / `+range-move` 用 `source-range` / `target-range`(不是 `range` / `destination_range`) +> - `+rows-resize` / `+cols-resize` 用 `start` / `end` / `type` / `size`(不是 `range` / `resize_height`) +> - `+dim-{insert|delete|hide|unhide|group|ungroup}` 用 `dimension` / `start` / `end` +> +> ```jsonc +> // 复制 A1:B2 到 A10,并把第 23 行行高设为 40px +> [ +> {"shortcut": "+range-copy", +> "input": {"sheet_id": "...", "source-range": "A1:B2", "target-range": "A10", "paste-type": "all"}}, +> {"shortcut": "+rows-resize", +> "input": {"sheet_id": "...", "start": 22, "end": 22, "type": "pixel", "size": 40}} +> ] +> ``` +> 注:`+sheet-move` 在批量内需显式提供 `sheet-id` 与 `source-index`(批量中途无法发起结构查询自动推导)。 + > **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。 > > ```jsonc