mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
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:
355
shortcuts/sheets/batch_op_contract_test.go
Normal file
355
shortcuts/sheets/batch_op_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
247
shortcuts/sheets/flag_view.go
Normal file
247
shortcuts/sheets/flag_view.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user