mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
be2a96f490
Aggregate the sheets work from feat/lark-sheets-develop: - Improve validation errors with schema hints, aggregated issues, enum guidance, and prescriptive flag/style-field messages. - Harden +batch-update input contracts, key normalization, style vocabulary handling, and resource-budget checks. - Add read offload and truncation handling for cells, csv, and table-get, with typed output-path errors and safer jq/output-path semantics. - Correct freeze semantics by emitting full-state freeze/unfreeze operations and adding --rows/--cols for +dim-freeze. - Improve +styles-put and shared --styles parsing for styles, merges, row/column sizing, freeze, and sheet-prefixed range validation. - Fix dim-insert inherit-style mapping, table-get date/time handling, table-put style anchors, and CSV path-shaped input guards. - Update lark-sheets skill docs, scripts, tests, and generated flag data. Tested with: - go test ./shortcuts/common ./shortcuts/sheets/... - go test ./shortcuts/... ./internal/... - python3 -m py_compile skills/lark-sheets/scripts/*.py
730 lines
27 KiB
Go
730 lines
27 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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 (fail-fast): execution stops at the
|
|
// first failing sub-op, but sub-ops already applied are NOT rolled back —
|
|
// the server reports "N succeeded, M failed" and the N stay in the sheet
|
|
// (verified against live batches; earlier docs wrongly promised a rollback,
|
|
// which made agents resend whole batches and double-apply the successes).
|
|
// CLI leaves the default in place for the fan-out shortcuts since they're
|
|
// idempotent stamps; 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 in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
|
|
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)
|
|
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
|
if warnings := batchWarnings(runtime); len(warnings) > 0 {
|
|
dr.Set("warning_message", strings.Join(warnings, "\n"))
|
|
}
|
|
return dr
|
|
},
|
|
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
|
|
}
|
|
for _, w := range batchWarnings(runtime) {
|
|
fmt.Fprintln(runtime.IO().ErrOut, w)
|
|
}
|
|
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(out, nil)
|
|
return nil
|
|
},
|
|
Tips: []string{
|
|
"high-risk-write: preview with --dry-run, get the user's explicit consent, then re-run with --yes appended — do not pass --yes before the user has confirmed (without it the call exits 10 asking for confirmation).",
|
|
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
|
|
"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
|
|
}
|
|
|
|
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
|
|
// requests --inherit-style before at the first row/column, where the
|
|
// preceding-side style cannot be copied (no preceding row/column exists).
|
|
// batchWarnings collects the advisory notes a batch surfaces before it runs,
|
|
// in one place so DryRun and Execute cannot drift apart on which ones they
|
|
// report.
|
|
func batchWarnings(runtime *common.RuntimeContext) []string {
|
|
var out []string
|
|
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
|
out = append(out, dimInsertBeforeStyleWarning)
|
|
}
|
|
out = append(out, batchCollidingDimFreezeNotes(runtime)...)
|
|
return append(out, batchLegacyDimFreezeNotes(runtime)...)
|
|
}
|
|
|
|
// batchCollidingDimFreezeNotes reports +dim-freeze sub-ops that target the SAME
|
|
// sheet more than once. Freeze is full-state replacement, so each of them
|
|
// discards the previous one and only the last survives — both still report
|
|
// success, which is exactly why the mistake goes unnoticed. The CLI has already
|
|
// walked the whole ops array by this point, so it can name the survivor and the
|
|
// single sub-op that holds everything the caller clearly meant to hold.
|
|
//
|
|
// A batch cannot read current state, and +styles-put (the other combined-freeze
|
|
// carrier) is not batchable, so folding into ONE sub-op is the only fix — hence
|
|
// a note rather than a suggestion to reorder.
|
|
func batchCollidingDimFreezeNotes(runtime *common.RuntimeContext) []string {
|
|
rawOps, err := parseBatchOperationsFlag(runtime)
|
|
if err != nil {
|
|
return nil // a malformed --operations is the translator's to report.
|
|
}
|
|
type freezeOp struct {
|
|
index int
|
|
rows, cols int
|
|
}
|
|
// Keyed by the sub-op's sheet selector: freezes on different sheets are
|
|
// independent. Order of first appearance keeps the notes deterministic.
|
|
bySheet := map[string][]freezeOp{}
|
|
var order []string
|
|
for i, raw := range rawOps {
|
|
op, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
|
|
continue
|
|
}
|
|
input, _ := op["input"].(map[string]interface{})
|
|
if input == nil {
|
|
continue
|
|
}
|
|
fv := newMapFlagViewForCommand("+dim-freeze", input)
|
|
rows, cols, ok := dimFreezeAxes(fv)
|
|
if !ok {
|
|
continue // an unusable sub-op is the translator's to report.
|
|
}
|
|
key := strings.TrimSpace(fv.Str("sheet-id")) + "\x00" + strings.TrimSpace(fv.Str("sheet-name"))
|
|
if _, seen := bySheet[key]; !seen {
|
|
order = append(order, key)
|
|
}
|
|
bySheet[key] = append(bySheet[key], freezeOp{index: i, rows: rows, cols: cols})
|
|
}
|
|
|
|
var notes []string
|
|
for _, key := range order {
|
|
ops := bySheet[key]
|
|
if len(ops) < 2 {
|
|
continue
|
|
}
|
|
indexes := make([]string, 0, len(ops))
|
|
// The combined state is what the caller almost certainly meant: keep the
|
|
// last positive value named for each axis. An axis nobody ever freezes
|
|
// stays 0, so a deliberate "unfreeze everything" batch still renders as
|
|
// --rows 0 --cols 0 rather than inventing a freeze.
|
|
combinedRows, combinedCols := 0, 0
|
|
for _, op := range ops {
|
|
indexes = append(indexes, fmt.Sprintf("operations[%d]", op.index))
|
|
if op.rows > 0 {
|
|
combinedRows = op.rows
|
|
}
|
|
if op.cols > 0 {
|
|
combinedCols = op.cols
|
|
}
|
|
}
|
|
last := ops[len(ops)-1]
|
|
notes = append(notes, fmt.Sprintf(
|
|
"warning: %s are all +dim-freeze on the same sheet — freeze replaces the WHOLE state, so each one discards the previous and only %s survives (ending at %s). They all report success. Replace them with ONE sub-op: %s",
|
|
strings.Join(indexes, ", "),
|
|
indexes[len(indexes)-1],
|
|
dimFreezeSpelling(last.rows, last.cols),
|
|
dimFreezeSpelling(combinedRows, combinedCols)))
|
|
}
|
|
return notes
|
|
}
|
|
|
|
// batchLegacyDimFreezeNotes steers +dim-freeze sub-ops still written in the
|
|
// deprecated --dimension/--count form (see DEPRECATED(phase-2) on
|
|
// dimFreezeLegacyNote). The standalone command prints that note from its own
|
|
// DryRun/Execute, which a sub-op never reaches — yet the batch is where the
|
|
// legacy form does the most damage: freeze is full-state replacement, so two
|
|
// per-axis sub-ops both report success while only the last axis stays frozen,
|
|
// and +styles-put (the other way to set both axes) is not batchable. The
|
|
// wording comes from the shared helper, so it cannot drift from the standalone
|
|
// one.
|
|
func batchLegacyDimFreezeNotes(runtime *common.RuntimeContext) []string {
|
|
rawOps, err := parseBatchOperationsFlag(runtime)
|
|
if err != nil {
|
|
return nil // a malformed --operations is the translator's to report.
|
|
}
|
|
var notes []string
|
|
for i, raw := range rawOps {
|
|
op, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
|
|
continue
|
|
}
|
|
input, _ := op["input"].(map[string]interface{})
|
|
if input == nil {
|
|
continue
|
|
}
|
|
if note := dimFreezeLegacyNote(newMapFlagViewForCommand("+dim-freeze", input)); note != "" {
|
|
notes = append(notes, fmt.Sprintf("operations[%d] (+dim-freeze): %s", i, note))
|
|
}
|
|
}
|
|
return notes
|
|
}
|
|
|
|
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
|
|
rawOps, err := parseBatchOperationsFlag(runtime)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, raw := range rawOps {
|
|
op, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
sc, _ := op["shortcut"].(string)
|
|
if sc != "+dim-insert" {
|
|
continue
|
|
}
|
|
input, _ := op["input"].(map[string]interface{})
|
|
isBefore := false
|
|
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
|
|
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
|
|
isBefore = true
|
|
break
|
|
}
|
|
}
|
|
if !isBefore {
|
|
continue
|
|
}
|
|
posRaw, hasPos := input["position"]
|
|
if !hasPos {
|
|
continue
|
|
}
|
|
// Warn only at the first row/column (idx 0).
|
|
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 batch request (fail-fast, no rollback).",
|
|
Risk: "write",
|
|
Scopes: []string{"sheets:spreadsheet:write_only"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
HasFormat: true,
|
|
Flags: flagsFor("+cells-batch-set-style"),
|
|
Tips: []string{
|
|
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
|
|
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
|
|
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
|
|
},
|
|
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
|
|
}
|
|
// DEPRECATED(phase-2): +cells-batch-set-style — replaced by +styles-put.
|
|
// Phase 1 (here): the command keeps working and is already retired from
|
|
// the skill docs via bundle.json doc_hidden_shortcuts in
|
|
// sheet-skill-spec; steer new usage to the superset in-band.
|
|
// Phase 2 removal: drop the shortcut from spec-tables + its
|
|
// doc_hidden_shortcuts entry, then this command and its input builder.
|
|
fmt.Fprintln(runtime.IO().ErrOut,
|
|
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
|
|
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
|
|
}
|
|
ops := make([]interface{}, 0, len(ranges))
|
|
var totalCells int64
|
|
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)
|
|
}
|
|
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
|
return nil, err
|
|
}
|
|
totalCells += int64(rows) * int64(cols)
|
|
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
|
|
return nil, 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 batch request (irreversible; fail-fast, no rollback).",
|
|
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"))
|
|
ops := make([]interface{}, 0, len(ranges))
|
|
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 in one batch request (fail-fast, no rollback).",
|
|
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 in one batch request (irreversible; fail-fast, no rollback).",
|
|
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
|
|
}
|
|
// validateDropdownRanges enforces the shared maxBatchRanges cap.
|
|
if _, err := validateDropdownRanges(runtime); err != nil {
|
|
return err
|
|
}
|
|
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}
|
|
}
|
|
ops := make([]interface{}, 0, len(ranges))
|
|
var totalCells int64
|
|
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)
|
|
}
|
|
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
|
|
return nil, err
|
|
}
|
|
totalCells += int64(rows) * int64(cols)
|
|
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
|
|
return nil, 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) ──────────
|
|
|
|
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
|
|
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
|
|
// the number of ops materialized into one batch_update.
|
|
const maxBatchRanges = 100
|
|
|
|
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
|
|
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
|
|
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
|
|
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
|
|
// summing past it. totalCells is int64 to stay overflow-safe.
|
|
func checkBatchStampBudget(flagName string, totalCells int64) error {
|
|
if totalCells > maxStampMatrixCells {
|
|
return sheetsValidationForFlag(flagName,
|
|
"the request expands to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
|
|
totalCells, maxStampMatrixCells)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
if len(out) > maxBatchRanges {
|
|
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
|
|
}
|
|
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
|
|
}
|