mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
decc9549b5
* feat(sheets): reject local-office tokens in +workbook-export
A locally opened Office file (a local_office_ / fake_office_ token, or an
interleaved OFL0X one) names a file the Lark client is showing, not a cloud
document, so the drive export task can only fail on the backend -- and it
fails late, after the create and poll round trips, with an opaque message.
Refuse it up front with a typed failed_precondition that says the workbook is
already a file on disk, and points at +workbook-import for callers who want a
cloud spreadsheet they can export later. The check runs in Validate (so
--dry-run is covered too) and again after the wiki hop in Execute, where the
real spreadsheet token is first known.
* refactor(sheets): report success-path advisories in the result, not on stderr
Every sheets shortcut that had something to say on a successful run said it on
stderr: ignored sub-op locators, emulated dimension semantics, the deprecated
--dimension/--count and +cells-batch-set-style spellings, the dropdown
option-error steer, and the upload/export stage lines in the compatibility
layer. PowerShell's native-command handling and most agent harnesses read
non-empty stderr as failure, so a working call reported itself as an error --
and the facts a caller actually needed sat outside the JSON they parse.
Pure stage text ("Writing image", "Waiting for export task") is deleted: it
duplicates what the result already proves. Everything decision-relevant moves
into the payload:
- data.warnings ignored locators, colliding freezes, the dropdown
option-error steer (also shown in --dry-run now)
- data.effective_operation +dim-insert's anchor shift under --inherit-style
before, and the whole (rows, cols) state a freeze
leaves behind
- data.deprecation +cells-batch-set-style and +dim-freeze's legacy
flag pair, under a key of its own rather than
mixed into warnings
- data.upload how +media-upload sent the file
Clean calls keep their exact previous payload shape: every field above is
added only when it has something to report.
Scope is shortcuts/sheets/** on purpose. The remaining success-path stderr in
this domain comes from shared code (the drive export/import core behind
+workbook-export / +workbook-import, the multipart media helper, the auto-grant
helper), which other domains share; cleaning those up belongs to their own
change. The one sheets-owned exception is +workbook-import's extension
correction, which has no slot in the import core's output envelope -- it is
documented at the call site and allowlisted in the guard test.
Tests pin the contract (a successful run leaves stderr empty) and each new
field, plus a source scan that stops new direct ErrOut writes from appearing.
* docs(sheets): point the dropdown option-error warning at data.warnings
The --source-range flag help still told callers the option-error steer arrives
on stderr; it now rides in the result. Mirrors the same edit in the upstream
spec (canonical-spec/spec-tables/flags.json), so the next sync is a no-op.
* fix(sheets): keep export identifiers in +export output, tighten the stderr guard
Review follow-ups on the success-path stderr change:
- +export --output-path lost file_token: on the download branch the token
reached the caller only through the deleted "Export complete: file_token=…"
stderr line, and the payload carried just saved_path and size_bytes. Both
file_token and ticket now ride in the download result, so a caller can
re-download or resume without re-running the export.
- The stderr guard allowlisted a whole file, hiding any future write in it.
It now matches one exact statement in one file and asserts that write still
exists, so both a new write and a stale exception fail the test.
- The contract comments claimed more than the tests prove. They now state
that only sheets-OWNED code is silent, name the three commands whose noise
comes from shared implementations (+workbook-export, +workbook-import,
+media-upload over 20MB), and a new test pins that the shared export core
does still write -- failing, by design, once that core is cleaned up.
* fix(drive): keep the export and import cores off stderr on success
+workbook-export and +workbook-import delegate to drive.RunExport /
drive.RunImport, so the sheets success-path contract could not hold while
those cores narrated every step: task creation, each poll attempt, completion,
"still in progress", and the import's media upload. Callers that read
non-empty stderr as failure saw a finished export report itself as an error.
The stage text is deleted -- ticket, ready, status, file_token, token and
next_command are all already in the payload. What the narration alone carried
moves into the result:
- poll attempts / transient_failures / last_error, added only
when a poll actually had to be retried, so a caller can
tell a clean run from one that limped to the finish
- warnings markdown export falling back to the token as file name
after a failed title lookup
- input_corrections a caller-supplied record of inputs the CLI rewrote
before the request ran; sheets +workbook-import uses it
for a mislabeled .xls that is really an .xlsx, which was
its last stderr write
drive +export / +import get the same treatment, since they share these cores.
Clean runs keep their exact previous payload shape.
With this, the sheets stderr guard needs no allowlist, and the contract test
covers both workbook commands end to end. Two shared paths a sheets caller can
still reach stay noisy and are named in the contract comment: multipart media
upload over 20MB, and the bot-identity auto-grant warning.
* test(sheets): cover the annotation shapes and both guard call sites
Review follow-ups, all test-side except one comment:
- +dim-insert's effective_operation had no test: a regression could drop the
emulated-anchor block and still keep stderr empty. Now asserted field by
field, plus the negative case (--inherit-style after rewrites nothing, so it
must not gain the block).
- The local-office guard's second call site had no test. A /wiki/ URL only
reveals its backing token after get_node runs in Execute, so that branch is
now covered, asserting both the typed rejection and that no export task was
created.
- annotateSheetsResult's three payload shapes are pinned: object annotated in
place, array/scalar preserved under `result`, and an empty tool result left
without an invented `result: null`. The doc comment now spells out that last
case instead of lumping it in with non-object output.
- The export poll summary test asserted transient_failures but not attempts,
so a wrong or missing count would have passed.
* fix: preserve recovery state on failure paths and TTY liveness during polls
Review round 2. Removing the success-path narration also removed information
from paths that fail after remote work has started, and removed the only
liveness signal an interactive user had:
- drive +import / sheets +workbook-import: once the import task exists, the
ticket is the only handle back to it. A poll failure returned bare, so the
ticket -- previously visible through the polling line -- was lost. It now
rides on the typed error together with the +task_result command.
- sheets +export --output-path: a download or save failure happens after the
artifact is ready, so the error now carries ticket, file_token and the
+export-download command; re-running the whole export is not the recovery.
A poll timeout carries the ticket for the same reason.
- sheets +batch-update / +batch-chart-*: batch_update is fail-fast without
rollback, so the ignored-locator and colliding-freeze advisories matter most
exactly when the call fails part-way -- they decide the safe retry set. They
are now attached to the typed error's hint as well as the success payload.
- Bounded polls and the import upload are wrapped in RuntimeContext.StartSpinner,
which is gated on StderrIsTerminal and is a strict no-op for pipes, CI and
captured output. A human terminal gets liveness back; a machine caller's
stderr stays empty (the contract tests, which capture stderr, still pass).
+workbook-export's rejection of Office tokens also stopped assuming the caller
holds the file: a local_office_ / fake_office_ prefix means the workbook is
already on their disk, but an interleaved OFL0X token is a file stored in Lark
that may never have been downloaded, so that class is now pointed at
drive +download (then +workbook-import if they want a Lark spreadsheet).
Each behaviour above has a regression test; httpmock's CapturedBodies doc
comment is corrected, since it is appended on every match, not only for
Reusable stubs.
1389 lines
49 KiB
Go
1389 lines
49 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package sheets
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"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"},
|
||
ConditionalScopes: []string{"sheets:spreadsheet:read"},
|
||
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 := buildBatchUpdatePlan(runtime, token); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
plan, _ := buildBatchUpdatePlan(runtime, token)
|
||
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", plan.input)
|
||
if batchContainsSemanticChartUpdate(runtime) {
|
||
dr.Set("preflight", "execution reads each target chart snapshot before building its partial properties patch")
|
||
}
|
||
if len(plan.localFailures) > 0 {
|
||
dr.Set("local_validation_failures", plan.localFailures)
|
||
}
|
||
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
|
||
}
|
||
plan, err := buildBatchUpdatePlan(runtime, token)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := prepareBatchChartUpdates(ctx, runtime, token, plan); err != nil {
|
||
return err
|
||
}
|
||
// Ignored sub-op locators and emulated / colliding semantics decide
|
||
// whether a caller can safely retry, so they travel with the result
|
||
// instead of on stderr, where a success-path write reads as a failure —
|
||
// and, since a fail-fast batch leaves earlier sub-ops applied, they must
|
||
// survive the failure path too.
|
||
warnings := batchWarnings(runtime)
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", plan.input)
|
||
if err != nil {
|
||
return attachSheetsWarningsToError(err, warnings)
|
||
}
|
||
if len(plan.localFailures) > 0 {
|
||
out = mergeBatchUpdatePartialOutput(out, plan)
|
||
}
|
||
out = appendSheetsWarnings(compactBatchChartCreateOutput(out), warnings)
|
||
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). Repeated input.excel_id / input.spreadsheet_token / input.url fields are ignored; the top-level locator wins.",
|
||
"Chart operations are supported, but prefer +batch-chart-create / +batch-chart-update for chart-only work because their contracts and partial-failure recovery are simpler.",
|
||
},
|
||
}
|
||
|
||
var chartCreateBatchDispatch = map[string]batchOpMapping{
|
||
"+chart-create-basic": {"manage_chart_object", chartCreateBasicInput},
|
||
}
|
||
|
||
var chartUpdateBatchDispatch = map[string]batchOpMapping{
|
||
"+chart-config-update": {"manage_chart_object", chartConfigUpdateInput},
|
||
"+chart-data-update": {"manage_chart_object", chartDataUpdateInput},
|
||
}
|
||
|
||
var BatchChartCreate = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+batch-chart-create",
|
||
Description: "Create multiple independent basic charts through one batch request; valid charts continue when another chart fails.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+batch-chart-create"),
|
||
Tips: []string{
|
||
"Each operation directly contains +chart-create-basic flags such as sheet_name, chart_type, and data_range; do not wrap it in shortcut/input. The legacy wrapped shape remains accepted for compatibility.",
|
||
"--dry-run prints the translated internal MCP body (tool_name / operation / basic_chart) for inspection only; never copy that body back into --operations.",
|
||
"Inspect succeeded, failed, and results after execution. Keep successful charts, retry only failed indexes, then call +chart-list once per affected sheet and repair mismatches with +batch-chart-update instead of deleting/recreating charts.",
|
||
},
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetToken(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = buildChartBatchPlan(runtime, token, chartCreateBatchDispatch, "+batch-chart-create")
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
plan, _ := buildChartBatchPlan(runtime, token, chartCreateBatchDispatch, "+batch-chart-create")
|
||
dryRun := invokeToolDryRun(token, ToolKindWrite, "batch_update", plan.input)
|
||
if len(plan.localFailures) > 0 {
|
||
dryRun.Set("local_validation_failures", plan.localFailures)
|
||
}
|
||
if warnings := batchIgnoredLocatorNotes(runtime, "+batch-chart-create"); len(warnings) > 0 {
|
||
dryRun.Set("warning_message", strings.Join(warnings, "\n"))
|
||
}
|
||
return dryRun
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
plan, err := buildChartBatchPlan(runtime, token, chartCreateBatchDispatch, "+batch-chart-create")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
warnings := batchIgnoredLocatorNotes(runtime, "+batch-chart-create")
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", plan.input)
|
||
if err != nil {
|
||
return attachSheetsWarningsToError(err, warnings)
|
||
}
|
||
if len(plan.localFailures) > 0 {
|
||
out = mergeBatchUpdatePartialOutput(out, plan)
|
||
}
|
||
out = appendSheetsWarnings(compactBatchChartCreateOutput(out), warnings)
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
var BatchChartUpdate = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+batch-chart-update",
|
||
Description: "Update multiple independent chart configurations or data sources through one batch request.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+batch-chart-update"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetToken(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = buildChartBatchPlan(runtime, token, chartUpdateBatchDispatch, "+batch-chart-update")
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
plan, _ := buildChartBatchPlan(runtime, token, chartUpdateBatchDispatch, "+batch-chart-update")
|
||
dryRun := invokeToolDryRun(token, ToolKindWrite, "batch_update", plan.input)
|
||
dryRun.Set("preflight", "execution reads each target chart snapshot before building its partial properties patch")
|
||
if len(plan.localFailures) > 0 {
|
||
dryRun.Set("local_validation_failures", plan.localFailures)
|
||
}
|
||
if warnings := batchIgnoredLocatorNotes(runtime, "+batch-chart-update"); len(warnings) > 0 {
|
||
dryRun.Set("warning_message", strings.Join(warnings, "\n"))
|
||
}
|
||
return dryRun
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
plan, err := buildChartBatchPlan(runtime, token, chartUpdateBatchDispatch, "+batch-chart-update")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := prepareBatchChartUpdates(ctx, runtime, token, plan); err != nil {
|
||
return err
|
||
}
|
||
warnings := batchIgnoredLocatorNotes(runtime, "+batch-chart-update")
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", plan.input)
|
||
if err != nil {
|
||
return attachSheetsWarningsToError(err, warnings)
|
||
}
|
||
if len(plan.localFailures) > 0 {
|
||
out = mergeBatchUpdatePartialOutput(out, plan)
|
||
}
|
||
out = appendSheetsWarnings(out, warnings)
|
||
runtime.Out(out, nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
func buildChartBatchPlan(
|
||
runtime *common.RuntimeContext,
|
||
token string,
|
||
dispatch map[string]batchOpMapping,
|
||
command string,
|
||
) (*batchUpdatePlan, error) {
|
||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if command == "+batch-chart-create" {
|
||
rawOps, err = normalizeChartCreateBatchOperations(rawOps)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if len(rawOps) == 0 {
|
||
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
|
||
}
|
||
if len(rawOps) > maxBatchOperations {
|
||
return nil, sheetsValidationForFlag(
|
||
"operations",
|
||
"--operations accepts at most %d entries; got %d",
|
||
maxBatchOperations,
|
||
len(rawOps),
|
||
)
|
||
}
|
||
continueOnError := true
|
||
if runtime.Changed("continue-on-error") {
|
||
continueOnError = runtime.Bool("continue-on-error")
|
||
}
|
||
translated := make([]interface{}, 0, len(rawOps))
|
||
originalIndexes := make([]int, 0, len(rawOps))
|
||
failures := make([]batchOpTranslationFailure, 0)
|
||
for index, raw := range rawOps {
|
||
item, translateErr := translateBatchOpWithDispatch(raw, token, index, dispatch, command)
|
||
if translateErr != nil {
|
||
shortcut := ""
|
||
if object, ok := raw.(map[string]interface{}); ok {
|
||
shortcut, _ = object["shortcut"].(string)
|
||
}
|
||
failures = append(failures, batchOpTranslationFailure{
|
||
Index: index,
|
||
Shortcut: shortcut,
|
||
Err: translateErr,
|
||
})
|
||
continue
|
||
}
|
||
translated = append(translated, item)
|
||
originalIndexes = append(originalIndexes, index)
|
||
}
|
||
if !continueOnError {
|
||
if err := batchOperationFailuresError(failures, len(rawOps)); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if len(translated) == 0 {
|
||
return nil, batchOperationFailuresError(failures, len(rawOps))
|
||
}
|
||
localFailures := make([]batchLocalValidationFailure, 0, len(failures))
|
||
for _, failure := range failures {
|
||
localFailures = append(localFailures, batchLocalValidationFailure{
|
||
Index: failure.Index,
|
||
Shortcut: failure.Shortcut,
|
||
Success: false,
|
||
Stage: "cli_validation",
|
||
Error: failure.Err.Error(),
|
||
})
|
||
}
|
||
plan := &batchUpdatePlan{
|
||
input: map[string]interface{}{
|
||
"excel_id": token,
|
||
"operations": translated,
|
||
"continue_on_error": continueOnError,
|
||
},
|
||
originalIndexes: originalIndexes,
|
||
normalizedOperations: normalizedBatchOperations(rawOps, originalIndexes),
|
||
localFailures: localFailures,
|
||
total: len(rawOps),
|
||
}
|
||
if err := rejectDuplicateBatchChartTargets(plan, nil); err != nil {
|
||
return nil, err
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func normalizeChartCreateBatchOperations(rawOps []interface{}) ([]interface{}, error) {
|
||
normalized := make([]interface{}, 0, len(rawOps))
|
||
for index, raw := range rawOps {
|
||
op, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil, sheetsValidationForFlag("operations", "operations[%d] must be a JSON object", index)
|
||
}
|
||
_, hasShortcut := op["shortcut"]
|
||
_, hasInput := op["input"]
|
||
if hasShortcut && hasInput {
|
||
normalized = append(normalized, op)
|
||
continue
|
||
}
|
||
|
||
input := make(map[string]interface{}, len(op))
|
||
for key, value := range op {
|
||
if key != "shortcut" && key != "input" {
|
||
input[key] = value
|
||
}
|
||
}
|
||
if hasInput {
|
||
rawInput := op["input"]
|
||
var inputObject map[string]interface{}
|
||
if rawInput != nil {
|
||
inputObject, ok = rawInput.(map[string]interface{})
|
||
if !ok {
|
||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'input' must be a JSON object (got %T)", index, rawInput)
|
||
}
|
||
}
|
||
input = inputObject
|
||
}
|
||
shortcut := "+chart-create-basic"
|
||
if hasShortcut {
|
||
value, ok := op["shortcut"].(string)
|
||
if !ok || strings.TrimSpace(value) == "" {
|
||
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' must be a non-empty string", index)
|
||
}
|
||
shortcut = value
|
||
}
|
||
normalized = append(normalized, map[string]interface{}{
|
||
"shortcut": shortcut,
|
||
"input": input,
|
||
})
|
||
}
|
||
return normalized, nil
|
||
}
|
||
|
||
func prepareBatchChartUpdates(
|
||
ctx context.Context,
|
||
runtime *common.RuntimeContext,
|
||
token string,
|
||
plan *batchUpdatePlan,
|
||
) error {
|
||
if batchChartTargetsNeedSheetLookup(plan) {
|
||
sheetIDsByName, _, err := listSheetIDsByName(ctx, runtime, token)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := rejectDuplicateBatchChartTargets(plan, sheetIDsByName); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
translated, _ := plan.input["operations"].([]interface{})
|
||
continueOnError, _ := plan.input["continue_on_error"].(bool)
|
||
prepared := make([]interface{}, 0, len(translated))
|
||
preparedIndexes := make([]int, 0, len(translated))
|
||
preparedNormalized := make([]batchNormalizedOperation, 0, len(translated))
|
||
for remoteIndex, rawIndex := range plan.originalIndexes {
|
||
normalized := plan.normalizedOperations[remoteIndex]
|
||
shortcut := normalized.shortcut
|
||
if shortcut != "+chart-config-update" && shortcut != "+chart-data-update" {
|
||
prepared = append(prepared, translated[remoteIndex])
|
||
preparedIndexes = append(preparedIndexes, rawIndex)
|
||
preparedNormalized = append(preparedNormalized, normalized)
|
||
continue
|
||
}
|
||
fv := newMapFlagViewForCommand(shortcut, normalized.input)
|
||
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
|
||
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
|
||
chartID := strings.TrimSpace(fv.Str("chart-id"))
|
||
snapshot, err := fetchChartSnapshot(ctx, runtime, token, sheetID, sheetName, chartID)
|
||
if err != nil {
|
||
if !continueOnError {
|
||
return err
|
||
}
|
||
plan.localFailures = append(plan.localFailures, batchLocalValidationFailure{
|
||
Index: rawIndex,
|
||
Shortcut: shortcut,
|
||
Success: false,
|
||
Stage: "cli_preflight",
|
||
Error: err.Error(),
|
||
})
|
||
continue
|
||
}
|
||
var body map[string]interface{}
|
||
switch shortcut {
|
||
case "+chart-config-update":
|
||
body, _, err = chartConfigUpdateInputFromSnapshot(fv, token, sheetID, sheetName, snapshot)
|
||
case "+chart-data-update":
|
||
body, _, _, _, err = chartDataUpdateInputFromSnapshot(fv, token, sheetID, sheetName, snapshot)
|
||
}
|
||
if err != nil {
|
||
if !continueOnError {
|
||
return err
|
||
}
|
||
plan.localFailures = append(plan.localFailures, batchLocalValidationFailure{
|
||
Index: rawIndex,
|
||
Shortcut: shortcut,
|
||
Success: false,
|
||
Stage: "cli_preflight",
|
||
Error: err.Error(),
|
||
})
|
||
continue
|
||
}
|
||
item, _ := translated[remoteIndex].(map[string]interface{})
|
||
item["input"] = body
|
||
prepared = append(prepared, item)
|
||
preparedIndexes = append(preparedIndexes, rawIndex)
|
||
preparedNormalized = append(preparedNormalized, normalized)
|
||
}
|
||
if len(prepared) == 0 {
|
||
return sheetsValidationForFlag("operations", "all chart updates failed CLI preflight; no write request was sent")
|
||
}
|
||
plan.input["operations"] = prepared
|
||
plan.originalIndexes = preparedIndexes
|
||
plan.normalizedOperations = preparedNormalized
|
||
return nil
|
||
}
|
||
|
||
func batchContainsSemanticChartUpdate(runtime *common.RuntimeContext) bool {
|
||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, raw := range rawOps {
|
||
op, _ := raw.(map[string]interface{})
|
||
shortcut, _ := op["shortcut"].(string)
|
||
if shortcut == "+chart-config-update" || shortcut == "+chart-data-update" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func compactBatchChartCreateOutput(out interface{}) interface{} {
|
||
root, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return out
|
||
}
|
||
results, _ := root["results"].([]interface{})
|
||
for _, raw := range results {
|
||
item, _ := raw.(map[string]interface{})
|
||
data, _ := item["data"].(map[string]interface{})
|
||
delete(data, "snapshot")
|
||
}
|
||
return root
|
||
}
|
||
|
||
type batchLocalValidationFailure struct {
|
||
Index int `json:"index"`
|
||
Shortcut string `json:"shortcut,omitempty"`
|
||
Success bool `json:"success"`
|
||
Stage string `json:"stage"`
|
||
Error string `json:"error"`
|
||
}
|
||
|
||
type batchNormalizedOperation struct {
|
||
shortcut string
|
||
input map[string]interface{}
|
||
}
|
||
|
||
type batchUpdatePlan struct {
|
||
input map[string]interface{}
|
||
originalIndexes []int
|
||
normalizedOperations []batchNormalizedOperation
|
||
localFailures []batchLocalValidationFailure
|
||
total int
|
||
}
|
||
|
||
func normalizedBatchOperations(rawOps []interface{}, originalIndexes []int) []batchNormalizedOperation {
|
||
operations := make([]batchNormalizedOperation, 0, len(originalIndexes))
|
||
for _, index := range originalIndexes {
|
||
raw, _ := rawOps[index].(map[string]interface{})
|
||
shortcut, _ := raw["shortcut"].(string)
|
||
input, _ := raw["input"].(map[string]interface{})
|
||
operations = append(operations, batchNormalizedOperation{shortcut: shortcut, input: input})
|
||
}
|
||
return operations
|
||
}
|
||
|
||
func buildBatchUpdatePlan(runtime *common.RuntimeContext, token string) (*batchUpdatePlan, error) {
|
||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
continueOnError := batchContinueOnError(runtime)
|
||
translated, originalIndexes, failures, err := collectBatchOperationTranslations(rawOps, token)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !continueOnError {
|
||
if err := batchOperationFailuresError(failures, len(rawOps)); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if len(translated) == 0 {
|
||
return nil, batchOperationFailuresError(failures, len(rawOps))
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operations": translated,
|
||
}
|
||
if continueOnError {
|
||
input["continue_on_error"] = true
|
||
}
|
||
localFailures := make([]batchLocalValidationFailure, 0, len(failures))
|
||
for _, failure := range failures {
|
||
localFailures = append(localFailures, batchLocalValidationFailure{
|
||
Index: failure.Index,
|
||
Shortcut: failure.Shortcut,
|
||
Success: false,
|
||
Stage: "cli_validation",
|
||
Error: failure.Err.Error(),
|
||
})
|
||
}
|
||
plan := &batchUpdatePlan{
|
||
input: input,
|
||
originalIndexes: originalIndexes,
|
||
normalizedOperations: normalizedBatchOperations(rawOps, originalIndexes),
|
||
localFailures: localFailures,
|
||
total: len(rawOps),
|
||
}
|
||
if err := rejectDuplicateBatchChartTargets(plan, nil); err != nil {
|
||
return nil, err
|
||
}
|
||
return plan, nil
|
||
}
|
||
|
||
func rejectDuplicateBatchChartTargets(plan *batchUpdatePlan, sheetIDsByName map[string]string) error {
|
||
type seenTarget struct {
|
||
index int
|
||
label string
|
||
}
|
||
seen := make(map[string]seenTarget)
|
||
for remoteIndex, operation := range plan.normalizedOperations {
|
||
if operation.shortcut != "+chart-config-update" && operation.shortcut != "+chart-data-update" {
|
||
continue
|
||
}
|
||
fv := newMapFlagViewForCommand(operation.shortcut, operation.input)
|
||
chartID := strings.TrimSpace(fv.Str("chart-id"))
|
||
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
|
||
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
|
||
if chartID == "" || (sheetID == "" && sheetName == "") {
|
||
continue // The operation translator reports missing required fields.
|
||
}
|
||
selectorKey := "id:" + sheetID
|
||
selectorLabel := "sheet-id " + strconv.Quote(sheetID)
|
||
if sheetID == "" {
|
||
selectorKey = "name:" + sheetName
|
||
selectorLabel = "sheet-name " + strconv.Quote(sheetName)
|
||
if resolvedID := sheetIDsByName[sheetName]; resolvedID != "" {
|
||
selectorKey = "id:" + resolvedID
|
||
}
|
||
}
|
||
key := selectorKey + "\x00" + chartID
|
||
originalIndex := plan.originalIndexes[remoteIndex]
|
||
if previous, ok := seen[key]; ok {
|
||
return sheetsValidationForFlag(
|
||
"operations",
|
||
"operations[%d] and operations[%d] both target chart %q on %s; send duplicate chart targets in separate batch calls so each update reads the latest snapshot",
|
||
previous.index,
|
||
originalIndex,
|
||
chartID,
|
||
previous.label,
|
||
)
|
||
}
|
||
seen[key] = seenTarget{index: originalIndex, label: selectorLabel}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func batchChartTargetsNeedSheetLookup(plan *batchUpdatePlan) bool {
|
||
selectorKinds := make(map[string]uint8)
|
||
for _, operation := range plan.normalizedOperations {
|
||
if operation.shortcut != "+chart-config-update" && operation.shortcut != "+chart-data-update" {
|
||
continue
|
||
}
|
||
fv := newMapFlagViewForCommand(operation.shortcut, operation.input)
|
||
chartID := strings.TrimSpace(fv.Str("chart-id"))
|
||
if chartID == "" {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(fv.Str("sheet-id")) != "" {
|
||
selectorKinds[chartID] |= 1
|
||
} else if strings.TrimSpace(fv.Str("sheet-name")) != "" {
|
||
selectorKinds[chartID] |= 2
|
||
}
|
||
if selectorKinds[chartID] == 3 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func batchContinueOnError(runtime *common.RuntimeContext) bool {
|
||
if runtime.Changed("continue-on-error") {
|
||
// An explicit false wins over the envelope.
|
||
return runtime.Bool("continue-on-error")
|
||
}
|
||
if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil {
|
||
if m, ok := envelope.(map[string]interface{}); ok {
|
||
if v, ok := m["continue_on_error"].(bool); ok {
|
||
return v
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// mergeBatchUpdatePartialOutput restores original operation indexes after the
|
||
// CLI omitted locally invalid operations from the server request, then appends
|
||
// the local failures to the same result list. The command exits successfully
|
||
// when the server preserved at least one valid operation, but the output still
|
||
// makes every failed operation explicit.
|
||
func mergeBatchUpdatePartialOutput(out interface{}, plan *batchUpdatePlan) interface{} {
|
||
merged := map[string]interface{}{}
|
||
if remote, ok := out.(map[string]interface{}); ok {
|
||
for key, value := range remote {
|
||
merged[key] = value
|
||
}
|
||
} else if out != nil {
|
||
merged["tool_output"] = out
|
||
}
|
||
|
||
results := make([]interface{}, 0, plan.total)
|
||
if remoteResults, ok := merged["results"].([]interface{}); ok {
|
||
for _, raw := range remoteResults {
|
||
item, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
results = append(results, raw)
|
||
continue
|
||
}
|
||
copied := make(map[string]interface{}, len(item))
|
||
for key, value := range item {
|
||
copied[key] = value
|
||
}
|
||
if remoteIndex, ok := batchResultIndex(copied["index"]); ok &&
|
||
remoteIndex >= 0 && remoteIndex < len(plan.originalIndexes) {
|
||
copied["index"] = plan.originalIndexes[remoteIndex]
|
||
}
|
||
results = append(results, copied)
|
||
}
|
||
}
|
||
for _, failure := range plan.localFailures {
|
||
results = append(results, map[string]interface{}{
|
||
"index": failure.Index,
|
||
"shortcut": failure.Shortcut,
|
||
"success": false,
|
||
"stage": failure.Stage,
|
||
"error": failure.Error,
|
||
})
|
||
}
|
||
sort.SliceStable(results, func(i, j int) bool {
|
||
left, leftOK := batchResultItemIndex(results[i])
|
||
right, rightOK := batchResultItemIndex(results[j])
|
||
return leftOK && rightOK && left < right
|
||
})
|
||
|
||
succeeded := batchResultCount(merged["succeeded"])
|
||
remoteFailed := batchResultCount(merged["failed"])
|
||
failed := remoteFailed + len(plan.localFailures)
|
||
merged["total"] = plan.total
|
||
merged["succeeded"] = succeeded
|
||
merged["failed"] = failed
|
||
merged["results"] = results
|
||
merged["local_validation_failures"] = plan.localFailures
|
||
merged["message"] = fmt.Sprintf("batch_update: %d succeeded, %d failed", succeeded, failed)
|
||
return merged
|
||
}
|
||
|
||
func batchResultIndex(value interface{}) (int, bool) {
|
||
switch typed := value.(type) {
|
||
case int:
|
||
return typed, true
|
||
case int64:
|
||
return int(typed), true
|
||
case float64:
|
||
return int(typed), typed == float64(int(typed))
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func batchResultItemIndex(value interface{}) (int, bool) {
|
||
item, ok := value.(map[string]interface{})
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
return batchResultIndex(item["index"])
|
||
}
|
||
|
||
func batchResultCount(value interface{}) int {
|
||
count, _ := batchResultIndex(value)
|
||
return count
|
||
}
|
||
|
||
// 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
|
||
out = append(out, batchIgnoredLocatorNotes(runtime, "+batch-update")...)
|
||
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
|
||
out = append(out, dimInsertBeforeStyleWarning)
|
||
}
|
||
out = append(out, batchCollidingDimFreezeNotes(runtime)...)
|
||
return append(out, batchLegacyDimFreezeNotes(runtime)...)
|
||
}
|
||
|
||
// batchIgnoredLocatorNotes makes the translator's intentional locator rewrite
|
||
// visible. Without this note, a caller can pass a different spreadsheet in a
|
||
// sub-op and mistake the ignored value for the actual target.
|
||
func batchIgnoredLocatorNotes(runtime *common.RuntimeContext, command string) []string {
|
||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||
if err != nil {
|
||
return nil // a malformed --operations is the translator's to report.
|
||
}
|
||
if command == "+batch-chart-create" {
|
||
rawOps, err = normalizeChartCreateBatchOperations(rawOps)
|
||
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
|
||
}
|
||
input, _ := op["input"].(map[string]interface{})
|
||
if input == nil {
|
||
continue
|
||
}
|
||
var ignored []string
|
||
for userKey := range input {
|
||
if isReservedSubOpKey(userKey) {
|
||
ignored = append(ignored, userKey)
|
||
}
|
||
}
|
||
if len(ignored) == 0 {
|
||
continue
|
||
}
|
||
sort.Strings(ignored)
|
||
shortcut, _ := op["shortcut"].(string)
|
||
notes = append(notes, fmt.Sprintf(
|
||
"warning: operations[%d] (%s) ignored input locator keys %s; the top-level %s --url/--spreadsheet-token locator is authoritative",
|
||
i, shortcut, strings.Join(ignored, ", "), command))
|
||
}
|
||
return notes
|
||
}
|
||
|
||
// 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
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||
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. The steer
|
||
// rides in the result under its own `deprecation` key instead of on
|
||
// stderr, which must stay empty on success.
|
||
// Phase 2 removal: drop the shortcut from spec-tables + its
|
||
// doc_hidden_shortcuts entry, then this command and its input builder.
|
||
runtime.Out(annotateSheetsDeprecation(out, cellsBatchSetStyleDeprecationNote), nil)
|
||
return nil
|
||
},
|
||
}
|
||
|
||
// cellsBatchSetStyleDeprecationNote steers callers to the superset command.
|
||
const cellsBatchSetStyleDeprecationNote = "+cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work"
|
||
|
||
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
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
input, _ := dropdownBatchInput(runtime, token, false)
|
||
dry := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||
if warning := dropdownSourceRangeHighlightWarning(runtime); warning != "" {
|
||
dry.Set("warning_message", warning)
|
||
}
|
||
return dry
|
||
},
|
||
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(appendSheetsWarnings(out, dropdownHighlightWarnings(runtime)), 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)
|
||
// scanSheetQualifier rather than a literal "!" scan: the separator has
|
||
// four equal spellings, so a full-width "工作表1!A1:B2" does carry a
|
||
// prefix and must not be told it doesn't.
|
||
if _, _, ok := scanSheetQualifier(s); !ok {
|
||
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").
|
||
//
|
||
// The grammar is splitRangeSheetPrefix's, so every --ranges item parses the way
|
||
// the same prefix does in --range: the full-width and backslash-escaped
|
||
// separators count, and a quoted name is unwrapped. The sheet returned here
|
||
// goes straight into a sub-op's "sheet_name", so keeping the quotes would ship
|
||
// a name the backend cannot find ('My Sheet' instead of My Sheet).
|
||
//
|
||
// err distinguishes only the malformed cases (empty side); "no prefix at all"
|
||
// is the caller's own check, which names the flag and the item index.
|
||
func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) {
|
||
sheet, sub, ok := splitRangeSheetPrefix(rng)
|
||
if !ok {
|
||
return "", "", sheetsValidationForFlag("range", "range %q must use sheet!range form", rng)
|
||
}
|
||
return sheet, sub, nil
|
||
}
|