bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
504 lines
17 KiB
Go
504 lines
17 KiB
Go
// 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
|
|
}
|