// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package sheets import ( "context" "strings" "github.com/larksuite/cli/shortcuts/common" ) // ─── lark_sheet_batch_update ────────────────────────────────────────── // // One tool (batch_update), four shortcuts: // // - +batch-update user supplies a CLI-shape operations array // [{shortcut, input}, ...]; CLI translates to // MCP shape {tool_name, input(+operation)} via // batchOpDispatch before invoking the tool // (high-risk-write — anything in batchOpDispatch // can be inside) // - +cells-batch-set-style fan a single style across many ranges // - +dropdown-update install/replace the same dropdown across // many ranges in one atomic batch // - +dropdown-delete clear data_validation across many ranges // (high-risk-write) // // The tool's contract (post-translation): // { excel_id, operations: [{tool_name, input}, ...], continue_on_error? } // // continue_on_error defaults to false (strict transaction): any failure // rolls back the whole batch. CLI leaves the default in place for the // three "fan-out" shortcuts since they're meant to be all-or-nothing; // only +batch-update lets callers flip it via --continue-on-error. // BatchUpdate accepts a CLI-shape operations array (each item // {shortcut, input}); on Validate / DryRun / Execute we translate each // sub-op via batchOpDispatch (see batch_op_dispatch.go) into the MCP // {tool_name, input(+operation)} form before calling the underlying // batch_update tool. var BatchUpdate = common.Shortcut{ Service: "sheets", Command: "+batch-update", Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).", Risk: "high-risk-write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+batch-update"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetToken(runtime) if err != nil { return err } // Run the full translation in Validate so shape errors surface before // DryRun / Execute. Translator is pure (no network), so re-running it // in DryRun / Execute below is fine. if _, err := batchUpdateInput(runtime, token); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) input, _ := batchUpdateInput(runtime, token) return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err } input, err := batchUpdateInput(runtime, token) if err != nil { return err } out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) if err != nil { return err } runtime.Out(out, nil) return nil }, Tips: []string{ "Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.", "Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).", }, } // batchUpdateInput translates the user-supplied CLI-shape operations array // into the MCP batch_update payload. Returns ValidationErrorf-typed errors // (errs.ValidationError) on any per-op shape problem (translator validates // each entry). func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { rawOps, err := parseBatchOperationsFlag(runtime) if err != nil { return nil, err } translated, err := translateBatchOperations(rawOps, token) if err != nil { return nil, err } input := map[string]interface{}{ "excel_id": token, "operations": translated, } if runtime.Changed("continue-on-error") { // An explicit --continue-on-error always wins over the envelope, so // --continue-on-error=false keeps the strict-transaction default even // when the --operations envelope carries continue_on_error:true. if runtime.Bool("continue-on-error") { input["continue_on_error"] = true } } else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil { // No explicit flag: honor an inline override when --operations is an // envelope object rather than a bare operations array. if m, ok := envelope.(map[string]interface{}); ok { if v, ok := m["continue_on_error"].(bool); ok && v { input["continue_on_error"] = true } } } return input, nil } // parseBatchOperationsFlag accepts --operations as either a JSON array (the // operations list directly) or an envelope object { operations, continue_on_error } // for back-compat with the legacy --data shape. Returns the operations array. func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, error) { v, err := parseJSONFlag(runtime, "operations") if err != nil { return nil, err } if v == nil { return nil, sheetsValidationForFlag("operations", "--operations is required") } if arr, ok := v.([]interface{}); ok { return arr, nil } if m, ok := v.(map[string]interface{}); ok { if ops, ok := m["operations"].([]interface{}); ok { return ops, nil } } return nil, sheetsValidationForFlag("operations", "--operations must be a JSON array (or { operations: [...] } envelope)") } // CellsBatchSetStyle stamps one style block across many sheet-prefixed // ranges atomically. --ranges is a JSON array of sheet-prefixed A1 // strings; the style is composed from the same flat flags as // +cells-set-style. CLI fans each range into a separate set_cell_range // op inside one batch_update. var CellsBatchSetStyle = common.Shortcut{ Service: "sheets", Command: "+cells-batch-set-style", Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cells-batch-set-style"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } if _, err := validateDropdownRanges(runtime); err != nil { return err } if err := requireAnyStyleFlag(runtime); err != nil { return err } if _, err := borderStylesFromFlag(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) input, _ := cellsBatchSetStyleInput(runtime, token) return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err } input, err := cellsBatchSetStyleInput(runtime, token) if err != nil { return err } out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) if err != nil { return err } runtime.Out(out, nil) return nil }, } func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { ranges, err := validateDropdownRanges(runtime) if err != nil { return nil, err } cellStyle := buildCellStyleFromFlags(runtime) borderStyles, err := borderStylesFromFlag(runtime) if err != nil { return nil, err } prototype := map[string]interface{}{} if len(cellStyle) > 0 { prototype["cell_styles"] = cellStyle } if borderStyles != nil { prototype["border_styles"] = borderStyles } var ops []interface{} for _, rng := range ranges { sheet, sub, err := splitSheetPrefixedRange(rng) if err != nil { return nil, err } rows, cols, err := rangeDimensions(sub) if err != nil { return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ "tool_name": "set_cell_range", "input": map[string]interface{}{ "excel_id": token, "sheet_name": sheet, "range": sub, "cells": cells, }, }) } return map[string]interface{}{ "excel_id": token, "operations": ops, }, nil } // CellsBatchClear clears content / formats / both across many sheet-prefixed // ranges in one atomic batch. --ranges is a JSON array of sheet-prefixed A1 // strings; --scope reuses the +cells-clear vocabulary (content / formats / // all). CLI fans each range into a separate clear_cell_range op inside one // batch_update. high-risk-write because clear is irreversible. var CellsBatchClear = common.Shortcut{ Service: "sheets", Command: "+cells-batch-clear", Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).", Risk: "high-risk-write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+cells-batch-clear"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } if _, err := validateDropdownRanges(runtime); err != nil { return err } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) input, _ := cellsBatchClearInput(runtime, token) return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err } input, err := cellsBatchClearInput(runtime, token) if err != nil { return err } out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) if err != nil { return annotateEmbeddedBlockClearErr(err) } runtime.Out(out, nil) return nil }, Tips: []string{ "high-risk-write — always preview with --dry-run; clear is not undoable.", "Every --ranges item must carry a sheet prefix (e.g. \"Sheet1!A1:A10\"); all ranges are cleared with the same --scope.", "Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.", }, } func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) { ranges, err := validateDropdownRanges(runtime) if err != nil { return nil, err } clearType := normalizeClearType(runtime.Str("scope")) var ops []interface{} for _, rng := range ranges { sheet, sub, err := splitSheetPrefixedRange(rng) if err != nil { return nil, err } ops = append(ops, map[string]interface{}{ "tool_name": "clear_cell_range", "input": map[string]interface{}{ "excel_id": token, "sheet_name": sheet, "range": sub, "clear_type": clearType, }, }) } return map[string]interface{}{ "excel_id": token, "operations": ops, }, nil } // DropdownUpdate installs/replaces a single dropdown on many ranges in one // atomic batch. Sheet ids come from the per-range sheet prefix. var DropdownUpdate = common.Shortcut{ Service: "sheets", Command: "+dropdown-update", Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.", Risk: "write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dropdown-update"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } if _, err := validateDropdownRanges(runtime); err != nil { return err } if _, err := validateDropdownSourceOrOptions(runtime); err != nil { return err } warnDropdownSourceRangeHighlight(runtime) return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) input, _ := dropdownBatchInput(runtime, token, false) return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err } input, err := dropdownBatchInput(runtime, token, false) if err != nil { return err } out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) if err != nil { return err } runtime.Out(out, nil) return nil }, } // DropdownDelete clears data_validation across many ranges atomically. var DropdownDelete = common.Shortcut{ Service: "sheets", Command: "+dropdown-delete", Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).", Risk: "high-risk-write", Scopes: []string{"sheets:spreadsheet:write_only"}, AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: flagsFor("+dropdown-delete"), Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { if _, err := resolveSpreadsheetToken(runtime); err != nil { return err } ranges, err := validateDropdownRanges(runtime) if err != nil { return err } if len(ranges) > 100 { return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges)) } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) input, _ := dropdownBatchInput(runtime, token, true) return invokeToolDryRun(token, ToolKindWrite, "batch_update", input) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { token, err := resolveSpreadsheetTokenExec(runtime) if err != nil { return err } input, err := dropdownBatchInput(runtime, token, true) if err != nil { return err } out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input) if err != nil { return err } runtime.Out(out, nil) return nil }, } // dropdownBatchInput builds the batch_update payload for both // +dropdown-update (clear=false, data_validation populated) and // +dropdown-delete (clear=true, data_validation: null). func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool) (map[string]interface{}, error) { ranges, err := validateDropdownRanges(runtime) if err != nil { return nil, err } var prototype map[string]interface{} if clear { prototype = map[string]interface{}{"data_validation": nil} } else { validation, err := buildDropdownValidation(runtime) if err != nil { return nil, err } prototype = map[string]interface{}{"data_validation": validation} } var ops []interface{} for _, rng := range ranges { sheet, sub, err := splitSheetPrefixedRange(rng) if err != nil { return nil, err } rows, cols, err := rangeDimensions(sub) if err != nil { return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) } cells := fillCellsMatrix(rows, cols, prototype) ops = append(ops, map[string]interface{}{ "tool_name": "set_cell_range", "input": map[string]interface{}{ "excel_id": token, "sheet_name": sheet, "range": sub, "cells": cells, }, }) } return map[string]interface{}{ "excel_id": token, "operations": ops, }, nil } // ─── helpers resurrected from B3 (used here + future skills) ────────── // validateDropdownRanges parses --ranges, requires every entry to carry a // sheet prefix, and returns the parsed list. func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { raw, err := requireJSONArray(runtime, "ranges") if err != nil { return nil, err } out := make([]string, 0, len(raw)) for i, v := range raw { s, ok := v.(string) if !ok { return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i) } s = strings.TrimSpace(s) if !strings.Contains(s, "!") { return nil, sheetsValidationForFlag("ranges", "--ranges[%d] (%q) must include a sheet prefix", i, s) } // Validate the sheet!range shape up front so malformed entries like // "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail // here at Validate instead of slipping through to DryRun/Execute. _, sub, err := splitSheetPrefixedRange(s) if err != nil { return nil, sheetsValidationForFlag("ranges", "--ranges[%d]: %v", i, err) } if _, _, err := rangeDimensions(sub); err != nil { return nil, sheetsValidationForFlag("ranges", "--ranges[%d] (%q): %v", i, s, err) } out = append(out, s) } return out, nil } // splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100"). func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) { idx := strings.Index(rng, "!") if idx <= 0 || idx == len(rng)-1 { return "", "", sheetsValidationForFlag("range", "range %q must use sheet!range form", rng) } return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil }