Files
larksuite--cli/shortcuts/sheets/lark_sheet_batch_update.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

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
}