mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 10:29:48 +08:00
Emit structured validation, API, network, file, and internal error envelopes for Sheets shortcuts so users and agents can recover from failed spreadsheet workflows using stable type, subtype, param, and code fields. Add Sheets domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
503 lines
17 KiB
Go
503 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 := resolveSpreadsheetToken(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 FlagErrorf-typed errors 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 := resolveSpreadsheetToken(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 := resolveSpreadsheetToken(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 := resolveSpreadsheetToken(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 := resolveSpreadsheetToken(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
|
|
}
|