fix(sheets): make +batch-update sub-ops reuse standalone flag→body translators

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.
This commit is contained in:
xiongyuanwen-byted
2026-05-21 10:41:42 +08:00
parent 0c2e5f5e5c
commit 0ea7c14e4a
13 changed files with 892 additions and 176 deletions

View File

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

View File

@@ -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 inputMCP 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 的 inputMCP 契约要求)。
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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}},
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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