Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

1208 lines
48 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── object CRUD shortcuts ────────────────────────────────────────────
//
// Six object skills (chart / pivot table / conditional format / filter /
// filter view / sparkline / float image) each expose a uniform create /
// update / delete trio backed by their manage_<obj>_object tool.
//
// Shared shape:
// excel_id + sheet_id|sheet_name + operation + [<obj>_id] + [properties]
//
// CLI `--data` is passed through as the tool's `properties` payload as-is —
// callers shape it per the spec doc for each object (which is what makes
// the surface narrow even though everything funnels through one tool).
//
// Five of the seven objects share the factory below (newObjectCRUDShortcuts).
// pivot opts into allowEmptySheetSelectorOnCreate=true so the backend can
// auto-create a placement sub-sheet when neither --sheet-id nor --sheet-name
// is given; it also exposes optional --target-position on create. filter is
// special-cased further down (no separate id flag — filter_id is implicit
// per sheet — and --range is a first-class create flag, not buried in --data).
// objectCRUDSpec describes a 3-shortcut create/update/delete cluster.
// idFlag / idField empty → no per-object id flag (only filter uses that
// today, and it has its own bespoke shortcuts further down).
type objectCRUDSpec struct {
commandPrefix string // e.g. "+chart" → +chart-create / -update / -delete
toolName string // e.g. "manage_chart_object"
idFlag string // e.g. "chart-id"
idField string // e.g. "chart_id"
// enhanceCreateInput / enhanceUpdateInput, when set, mutate the tool
// input after the standard fields are written. Used to inject
// shortcut-specific flat flags into the input (typically into the
// properties map). The callback is responsible for navigating to the
// right nesting level.
enhanceCreateInput func(rt flagView, input map[string]interface{})
enhanceUpdateInput func(rt flagView, input map[string]interface{})
// validateCreateInput, when set, runs after enhanceCreateInput to
// enforce cross-flag / cross-field, create-only constraints JSON
// Schema can't express. Two uses today:
// - pivot rejects --target-position vs --range when both carry
// non-default values — they map to the same wire field and
// conflicting values are ambiguous (needs raw flags via rt).
// - cond-format requires every properties.attrs entry to match the
// sibling rule_type's shape (see validateCondFormatAttrs); a
// colorScale rule fed cellIs-shaped attrs writes a color-less
// segment that breaks the sheet on open (inspects input only).
// It is the create-path twin of validateUpdateInput; the same scope
// notes apply. Validators that only inspect the wire input can ignore
// the rt argument.
validateCreateInput func(rt flagView, input map[string]interface{}) error
// validateUpdateInput, when set, runs after enhanceUpdateInput to
// enforce *cross-field, update-only* constraints JSON Schema can't
// express (e.g. sparkline requires properties.sparklines[i] to
// carry sparkline_id on update — same schema is shared with create
// where the id is server-assigned). Type / enum / required /
// nested-shape checks are not handled here: they run automatically
// against data/flag-schemas.json in objectCreateInput /
// objectUpdateInput via validatePropertiesAgainstSchema.
validateUpdateInput func(input map[string]interface{}) error
// allowEmptySheetSelectorOnCreate, when true, makes the *create*
// shortcut accept empty --sheet-id / --sheet-name (backend then picks
// the placement target — e.g. manage_pivot_table_object auto-creates
// a sub-sheet to host the pivot). Both flags being set is still
// rejected. Update/delete continue to require an explicit selector.
// Today only pivotSpec opts in.
allowEmptySheetSelectorOnCreate bool
// createSheetIDFlag / createSheetNameFlag override the default
// `sheet-id` / `sheet-name` flag names on the *create* shortcut and
// its +batch-update sub-op. Used by pivot to expose
// `target-sheet-id` / `target-sheet-name` — the placement target,
// semantically distinct from the data-source sheet (which is encoded
// in --source as `'SheetName'!Range`). Empty = default names.
// Update/delete continue to use `sheet-id` / `sheet-name`.
createSheetIDFlag string
createSheetNameFlag string
// createTips, when set, populates the create shortcut's --help TIPS
// section. Used by pivot to make "omit --target-* → backend auto-creates
// a sub-sheet, zero overwrite" a hard, can't-miss note at the point of
// use (the most-stepped-on #REF! trap in real trajectories).
createTips []string
// createWarn, when set, is evaluated on the create shortcut's dry-run and
// execute paths; a non-empty return is surfaced as a `placement_warning`
// field in the output. Used by pivot to flag a likely source-data overwrite
// before it happens, without blocking the call. Local-only (no network), so
// it stays safe to call from dry-run.
createWarn func(rt flagView) string
}
// sheetIDFlagOnCreate / sheetNameFlagOnCreate return the cobra flag name
// used to read the placement-sheet selector on this spec's create
// shortcut. Defaults to `sheet-id` / `sheet-name`.
func (s objectCRUDSpec) sheetIDFlagOnCreate() string {
if s.createSheetIDFlag != "" {
return s.createSheetIDFlag
}
return "sheet-id"
}
func (s objectCRUDSpec) sheetNameFlagOnCreate() string {
if s.createSheetNameFlag != "" {
return s.createSheetNameFlag
}
return "sheet-name"
}
func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
flags := flagsFor(spec.commandPrefix + "-create")
return common.Shortcut{
Service: "sheets",
Command: spec.commandPrefix + "-create",
Description: "Create a " + strings.TrimPrefix(spec.commandPrefix, "+") + " object via the manage_*_object tool.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Tips: spec.createTips,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate()))
sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate()))
_, err = objectCreateInput(runtime, token, sheetID, sheetName, spec)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate()))
sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate()))
input, _ := objectCreateInput(runtime, token, sheetID, sheetName, spec)
dr := invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
if spec.createWarn != nil {
if w := spec.createWarn(runtime); w != "" {
dr = dr.Set("placement_warning", w)
}
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str(spec.sheetIDFlagOnCreate()))
sheetName := strings.TrimSpace(runtime.Str(spec.sheetNameFlagOnCreate()))
input, err := objectCreateInput(runtime, token, sheetID, sheetName, spec)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input)
if err != nil {
return err
}
if spec.createWarn != nil {
if w := spec.createWarn(runtime); w != "" {
if m, ok := out.(map[string]interface{}); ok {
m["placement_warning"] = w
}
}
}
runtime.Out(out, nil)
return nil
},
}
}
func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) {
var err error
if spec.allowEmptySheetSelectorOnCreate {
err = optionalSheetSelector(sheetID, sheetName, spec.sheetIDFlagOnCreate(), spec.sheetNameFlagOnCreate())
} else {
err = requireSheetSelector(sheetID, sheetName)
}
if err != nil {
return nil, err
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"operation": "create",
"properties": props,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if spec.enhanceCreateInput != nil {
spec.enhanceCreateInput(runtime, input)
}
if spec.validateCreateInput != nil {
if err := spec.validateCreateInput(runtime, input); err != nil {
return nil, err
}
}
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
return input, nil
}
func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut {
flags := flagsFor(spec.commandPrefix + "-update")
return common.Shortcut{
Service: "sheets",
Command: spec.commandPrefix + "-update",
Description: "Update an existing " + strings.TrimPrefix(spec.commandPrefix, "+") + " object (read-modify-write; consult --list first).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = objectUpdateInput(runtime, token, sheetID, sheetName, spec)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := objectUpdateInput(runtime, token, sheetID, sheetName, spec)
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := objectUpdateInput(runtime, token, sheetID, sheetName, spec)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
func objectUpdateInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
return nil, sheetsValidationForFlag(spec.idFlag, "--%s is required", spec.idFlag)
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"properties": props,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if spec.idFlag != "" {
input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag))
}
if spec.enhanceUpdateInput != nil {
spec.enhanceUpdateInput(runtime, input)
}
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
if spec.validateUpdateInput != nil {
if err := spec.validateUpdateInput(input); err != nil {
return nil, err
}
}
return input, nil
}
func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut {
flags := flagsFor(spec.commandPrefix + "-delete")
return common.Shortcut{
Service: "sheets",
Command: spec.commandPrefix + "-delete",
Description: "Delete a " + strings.TrimPrefix(spec.commandPrefix, "+") + " object (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
_, err = objectDeleteInput(runtime, token, sheetID, sheetName, spec)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := objectDeleteInput(runtime, token, sheetID, sheetName, spec)
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := objectDeleteInput(runtime, token, sheetID, sheetName, spec)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, spec.toolName, input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
func objectDeleteInput(runtime flagView, token, sheetID, sheetName string, spec objectCRUDSpec) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if spec.idFlag != "" && strings.TrimSpace(runtime.Str(spec.idFlag)) == "" {
return nil, sheetsValidationForFlag(spec.idFlag, "--%s is required", spec.idFlag)
}
input := map[string]interface{}{
"excel_id": token,
"operation": "delete",
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if spec.idFlag != "" {
input[spec.idField] = strings.TrimSpace(runtime.Str(spec.idFlag))
}
return input, nil
}
// ─── per-object instantiations ────────────────────────────────────────
// chart
var chartSpec = objectCRUDSpec{
commandPrefix: "+chart",
toolName: "manage_chart_object",
idFlag: "chart-id",
idField: "chart_id",
}
var ChartCreate = newObjectCreateShortcut(chartSpec)
var ChartUpdate = newObjectUpdateShortcut(chartSpec)
var ChartDelete = newObjectDeleteShortcut(chartSpec)
// pivot — create exposes --target-position (top-level of the tool input)
// plus --source / --range hoisted from properties. --sheet-id / --sheet-name
// are the placement target (where the pivot table lands); the backend
// auto-creates a new sub-sheet when both are omitted, so create opts into
// allowEmptySheetSelectorOnCreate.
var pivotSpec = objectCRUDSpec{
commandPrefix: "+pivot",
toolName: "manage_pivot_table_object",
idFlag: "pivot-table-id",
idField: "pivot_table_id",
allowEmptySheetSelectorOnCreate: true,
createSheetIDFlag: "target-sheet-id",
createSheetNameFlag: "target-sheet-name",
createTips: []string{
"Placement: omit --target-sheet-id / --target-sheet-name and the backend auto-creates a fresh sub-sheet for the pivot — zero overwrite risk. This is the default and the recommended path.",
"Only pass --target-sheet-id/-name to land in an existing sheet; if that sheet holds the source data you MUST set --target-position (or --range) outside the data, else the pivot overwrites it and the anchor shows #REF!.",
"Removing a stray pivot is +pivot-delete (get its id from +pivot-list); +cells-clear / +cells-batch-clear only clear cell values/formats and cannot delete the pivot object.",
},
createWarn: pivotPlacementWarn,
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return
}
if v := strings.TrimSpace(rt.Str("source")); v != "" {
props["source"] = v
}
// --target-position 与 --range 都映射到 properties.range
// --target-position 优先,未给(或为默认值 A1)时回落到 --range。
// 互斥校验在 validateCreateInput 里做。
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
props["range"] = v
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
props["range"] = v
}
},
// --target-position 与 --range 落到同一 wire 字段(properties.range),
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
// 的处理方式,CLI 端直接拒绝(优于静默丢弃其一)。
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
pos := strings.TrimSpace(rt.Str("target-position"))
rng := strings.TrimSpace(rt.Str("range"))
if pos != "" && pos != "A1" && rng != "" {
return common.ValidationErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
}
return nil
},
}
var PivotCreate = newObjectCreateShortcut(pivotSpec)
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
var PivotDelete = newObjectDeleteShortcut(pivotSpec)
// pivotPlacementWarn flags the one +pivot-create combination that silently
// overwrites data: an explicit placement sheet (--target-sheet-id/-name) with
// no offset (--target-position unset or A1, and no --range), so the pivot lands
// at A1 of an existing sheet. When that sheet is demonstrably the source-data
// sheet — target given by name, source carries a sheet prefix, names match —
// the warning is definite. When placement is by id (or the source has no
// prefix) the two can't be compared without a workbook lookup, which dry-run
// must avoid, so a conditional reminder is emitted instead. Returns "" when
// placement is safe (no target, or an offset was given). Advisory only: it is
// surfaced as placement_warning and never blocks the call.
func pivotPlacementWarn(rt flagView) string {
tgtID := strings.TrimSpace(rt.Str("target-sheet-id"))
tgtName := strings.TrimSpace(rt.Str("target-sheet-name"))
if tgtID == "" && tgtName == "" {
return "" // default path — backend auto-creates a sub-sheet, zero overwrite.
}
if pos := strings.TrimSpace(rt.Str("target-position")); pos != "" && pos != "A1" {
return "" // caller steered the pivot off A1.
}
if strings.TrimSpace(rt.Str("range")) != "" {
return "" // --range offset given.
}
srcSheet := sheetNameFromA1(rt.Str("source"))
if tgtName != "" && srcSheet != "" {
if strings.EqualFold(tgtName, srcSheet) {
return fmt.Sprintf("--target-sheet-name %q is the source-data sheet and no --target-position is set: "+
"the pivot lands at A1 and overwrites the source (the anchor then shows #REF!). Set --target-position "+
"to a blank cell outside the data, or omit --target-* to auto-create a sub-sheet.", tgtName)
}
return "" // distinct named sheet — safe.
}
return "a placement sheet is set without --target-position: if it is the source-data sheet, the pivot lands " +
"at A1 and overwrites the source (the anchor then shows #REF!). Set --target-position to a blank cell " +
"outside the data, or omit --target-* to auto-create a sub-sheet."
}
// sheetNameFromA1 extracts the sheet name from a sheet-prefixed A1 reference,
// stripping the single quotes Lark wraps around names that contain spaces:
// "'Sheet 1'!A1:D100" → "Sheet 1", "Data!A1" → "Data". Returns "" when there
// is no sheet prefix. (splitSheetPrefixedRange keeps the quotes; this one drops
// them, which is what name comparison needs.)
func sheetNameFromA1(ref string) string {
ref = strings.TrimSpace(ref)
idx := strings.Index(ref, "!")
if idx <= 0 {
return ""
}
name := strings.TrimSpace(ref[:idx])
if len(name) >= 2 && strings.HasPrefix(name, "'") && strings.HasSuffix(name, "'") {
name = name[1 : len(name)-1]
}
return name
}
// conditional format — CLI surface uses --rule-id (short), wired to the
// tool's conditional_format_id on the wire. --rule-type and --ranges are
// hoisted out of properties (both required, set on every CRUD write).
//
// Wire shape matches manage_conditional_format_object.properties — the
// enum value lives at properties.rule_type (flat string, NOT nested under
// a `rule` object), and ranges is a sibling array. Earlier CLI builds
// wrote properties.rule.type which the server silently dropped — both
// path and enum vocabulary are now aligned with the server schema (see
// sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json line
// 3305-3324).
var condFormatEnhance = func(rt flagView, input map[string]interface{}) {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return
}
if ruleType := strings.TrimSpace(rt.Str("rule-type")); ruleType != "" {
props["rule_type"] = ruleType
}
if rt.Str("ranges") != "" {
if arr, err := requireJSONArray(rt, "ranges"); err == nil {
props["ranges"] = arr
}
}
}
var condFormatSpec = objectCRUDSpec{
commandPrefix: "+cond-format",
toolName: "manage_conditional_format_object",
idFlag: "rule-id",
idField: "conditional_format_id",
enhanceCreateInput: condFormatEnhance,
enhanceUpdateInput: condFormatEnhance,
// validateCondFormatAttrs only inspects the wire input, so the create
// hook ignores rt; the update hook (func(input)) calls it directly.
validateCreateInput: func(_ flagView, input map[string]interface{}) error {
return validateCondFormatAttrs(input)
},
validateUpdateInput: validateCondFormatAttrs,
}
// condFormatAttrsRequired maps each conditional-format rule_type to the
// keys every properties.attrs entry must carry for that rule. It mirrors
// the per-rule attrs contract the tool's manage_conditional_format_object
// converter reads (byted-sheet ai-tools manage-conditional-format-object.ts):
// that converter maps each attrs entry *blindly by rule_type*, so a
// colorScale rule fed cellIs-shaped attrs ({compare_type,value}) silently
// yields a color-less color-scale segment — dirty data that crashes the
// frontend on snapshot deserialization (the 5005 "can't open" report this
// validator was added for).
//
// JSON Schema can't catch this: properties.attrs.items is a oneOf over all
// nine shapes, and the validator accepts an entry as soon as *any* branch
// matches — blind to the sibling rule_type. {compare_type,value} matches
// the cellIs branch regardless of whether rule_type says colorScale.
//
// Rule types absent from the map (duplicateValues, uniqueValues,
// containsBlanks, notContainsBlanks) carry no attrs, so nothing to check.
// Counts (dataBar==2, colorScale 23, iconSet ordering) stay the tool's
// job — it already rejects those with actionable messages; the gap this
// closes is per-entry *shape*, which the tool does not check.
var condFormatAttrsRequired = map[string][]string{
"cellIs": {"compare_type", "value"},
"containsText": {"compare_type", "text"},
"timePeriod": {"operator", "time_period"},
"dataBar": {"color", "value_type"},
"colorScale": {"value_type", "color"},
"rank": {"is_bottom", "value_type"},
"aboveAverage": {"operator"},
"expression": {"formula"},
"iconSet": {"icon_type", "value_type", "operator"},
}
// validateCondFormatAttrs enforces that every properties.attrs entry
// matches the shape required by the sibling properties.rule_type. Shared
// by create and update. On update, rule_type may be omitted (the caller is
// editing style only and the existing rule's type governs the attrs shape,
// which the CLI can't see); in that case validation is deferred to the
// server. Missing/empty attrs is likewise left to the tool, which already
// reports "attrs are required for rule_type: X" clearly.
func validateCondFormatAttrs(input map[string]interface{}) error {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return nil
}
ruleType, _ := props["rule_type"].(string)
ruleType = strings.TrimSpace(ruleType)
if ruleType == "" {
return nil
}
required, ok := condFormatAttrsRequired[ruleType]
if !ok {
return nil
}
attrs, ok := props["attrs"].([]interface{})
if !ok {
// Missing attrs, or a non-array shape the schema check already
// flagged — nothing for this cross-field rule to add.
return nil
}
for i, entryRaw := range attrs {
entry, ok := entryRaw.(map[string]interface{})
if !ok {
continue // schema validation owns per-entry type errors.
}
for _, key := range required {
if v, has := entry[key]; !has || condAttrIsBlank(v) {
return common.ValidationErrorf(
"--properties: attrs[%d] is missing %q, which rule_type %q requires on every entry (expected keys %s; got %s). "+
"A common cause is reusing another rule's attrs shape — e.g. cellIs-style {compare_type,value} under a colorScale rule, which writes a color-less segment that breaks the sheet on open.",
i, key, ruleType, strings.Join(required, "+"), condAttrPresentKeys(entry))
}
}
}
return nil
}
// condAttrIsBlank treats a present-but-empty string (after trimming) as
// missing. The crash-causing case is an empty `color`, but an empty value
// for any required key is never meaningful in these branches, so the rule
// is uniform. Non-string values (numbers, booleans) count as present.
func condAttrIsBlank(v interface{}) bool {
if v == nil {
return true
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
// condAttrPresentKeys lists the keys actually present on an attrs entry,
// sorted, for the "got ..." half of the error message.
func condAttrPresentKeys(entry map[string]interface{}) string {
if len(entry) == 0 {
return "{}"
}
keys := make([]string, 0, len(entry))
for k := range entry {
keys = append(keys, k)
}
sort.Strings(keys)
return "{" + strings.Join(keys, ",") + "}"
}
var CondFormatCreate = newObjectCreateShortcut(condFormatSpec)
var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec)
var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec)
// sparkline — CLI uses --group-id (higher level) as the object selector.
// Two-layer ID model: --group-id picks the sparkline group; individual
// items inside properties.sparklines[] are addressed by sparkline_id.
// On update the server requires sparkline_id on every item (it's how
// the server maps each entry back to an existing sparkline);
// validateSparklineUpdateItems surfaces that requirement CLI-side with
// a pointer to +sparkline-list instead of letting the caller hit a
// server-side rejection that doesn't mention sparkline_id at all.
//
// (sparkline-delete is intentionally not pre-checked here:
// objectDeleteInput doesn't pass properties through, so the partial-
// delete branch — properties.sparklines: [{sparkline_id}] — silently
// degrades to whole-group delete today. Surfacing that gap is a
// separate fix; this validator stays scoped to update.)
func validateSparklineUpdateItems(input map[string]interface{}) error {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return nil
}
raw, ok := props["sparklines"]
if !ok {
return nil // config-only update — fine
}
arr, ok := raw.([]interface{})
if !ok {
return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines must be an array")
}
for i, item := range arr {
m, _ := item.(map[string]interface{})
if m == nil {
return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines[%d] must be an object", i)
}
id, _ := m["sparkline_id"].(string)
if strings.TrimSpace(id) == "" {
return sheetsValidationForFlag("properties", "+sparkline-update properties.sparklines[%d] missing sparkline_id (run `+sparkline-list --group-id <id>` first to read sparkline_id for each item, then echo each id back on the corresponding update entry)", i)
}
}
return nil
}
var sparklineSpec = objectCRUDSpec{
commandPrefix: "+sparkline",
toolName: "manage_sparkline_object",
idFlag: "group-id",
idField: "group_id",
validateUpdateInput: validateSparklineUpdateItems,
}
var SparklineCreate = newObjectCreateShortcut(sparklineSpec)
var SparklineUpdate = newObjectUpdateShortcut(sparklineSpec)
var SparklineDelete = newObjectDeleteShortcut(sparklineSpec)
// float image — fully hoisted to 10 flat flags. No --properties flag;
// the tool's properties is composed entirely from the position / size /
// offset / image_token / image_uri / z_index flat flags.
// floatImageUploadPlaceholder is the stand-in image_token shown in
// Validate/DryRun for the --image (local upload) path, before the real
// file_token is known. Execute replaces it with the uploaded token.
const floatImageUploadPlaceholder = "<file_token>"
// floatImageName resolves the image name: explicit --image-name wins,
// otherwise fall back to the basename of a local --image path.
func floatImageName(runtime flagView) string {
if n := strings.TrimSpace(runtime.Str("image-name")); n != "" {
return n
}
if img := strings.TrimSpace(runtime.Str("image")); img != "" {
return filepath.Base(img)
}
return ""
}
// floatImageProperties assembles the tool's properties object from the flat
// flags. The manage_float_image tool requires image_name, position and size on
// both create and update; the only difference is the image source:
// - create (requireImageSource=true): exactly one of --image / --image-token
// / --image-uri must be set.
// - update (requireImageSource=false): the image source is optional — omit
// all three to keep the current image; when given it stays mutually
// exclusive. Despite the "patch" framing, the tool still rejects an update
// missing image_name, position or size, and +float-image-list does not
// return image_name for the CLI to backfill, so the caller must supply the
// full core set.
//
// image_name, position and size are cobra-required on both create and update,
// so the standalone path is already gated by the flag layer; the explicit
// checks below are what enforces them on the +batch-update sub-op path, which
// has no cobra layer (mirrors the --float-image-id check in floatImageWriteInput).
//
// uploadedImageToken, when non-empty, is the file_token obtained by uploading a
// local --image (Execute only); in Validate/DryRun it is "" and a placeholder
// token stands in.
func floatImageProperties(runtime flagView, uploadedImageToken string, requireImageSource bool) (map[string]interface{}, error) {
img := strings.TrimSpace(runtime.Str("image"))
token := strings.TrimSpace(runtime.Str("image-token"))
uri := strings.TrimSpace(runtime.Str("image-uri"))
set := 0
for _, v := range []string{img, token, uri} {
if v != "" {
set++
}
}
if set == 0 && requireImageSource {
return nil, common.ValidationErrorf("one of --image, --image-token, or --image-uri is required").WithParams(sheetsInvalidParam("image", "required; specify one"), sheetsInvalidParam("image-token", "required; specify one"), sheetsInvalidParam("image-uri", "required; specify one"))
}
if set > 1 {
params := make([]errs.InvalidParam, 0, 3)
if img != "" {
params = append(params, sheetsInvalidParam("image", "mutually exclusive"))
}
if token != "" {
params = append(params, sheetsInvalidParam("image-token", "mutually exclusive"))
}
if uri != "" {
params = append(params, sheetsInvalidParam("image-uri", "mutually exclusive"))
}
return nil, common.ValidationErrorf("--image, --image-token, and --image-uri are mutually exclusive").WithParams(params...)
}
name := floatImageName(runtime)
if name == "" {
return nil, sheetsValidationForFlag("image-name", "--image-name is required")
}
if !runtime.Changed("position-row") || !runtime.Changed("position-col") {
params := make([]errs.InvalidParam, 0, 2)
if !runtime.Changed("position-row") {
params = append(params, sheetsInvalidParam("position-row", "required"))
}
if !runtime.Changed("position-col") {
params = append(params, sheetsInvalidParam("position-col", "required"))
}
return nil, common.ValidationErrorf("--position-row and --position-col are required").WithParams(params...)
}
if !runtime.Changed("size-width") || !runtime.Changed("size-height") {
params := make([]errs.InvalidParam, 0, 2)
if !runtime.Changed("size-width") {
params = append(params, sheetsInvalidParam("size-width", "required"))
}
if !runtime.Changed("size-height") {
params = append(params, sheetsInvalidParam("size-height", "required"))
}
return nil, common.ValidationErrorf("--size-width and --size-height are required").WithParams(params...)
}
props := map[string]interface{}{
"image_name": name,
"position": map[string]interface{}{
"row": runtime.Int("position-row"),
"col": strings.TrimSpace(runtime.Str("position-col")),
},
"size": map[string]interface{}{
"width": runtime.Int("size-width"),
"height": runtime.Int("size-height"),
},
}
switch {
case img != "":
// Local file: validate path safety here so --dry-run also rejects
// unsafe paths; Execute uploads it and passes the real token in.
if _, err := validate.SafeLocalFlagPath("--image", img); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--image").
WithCause(err)
}
if uploadedImageToken != "" {
props["image_token"] = uploadedImageToken
} else {
props["image_token"] = floatImageUploadPlaceholder
}
case token != "":
props["image_token"] = token
case uri != "":
props["image_uri"] = uri
}
if runtime.Changed("offset-row") || runtime.Changed("offset-col") {
offset := map[string]interface{}{}
if runtime.Changed("offset-row") {
offset["row_offset"] = runtime.Int("offset-row")
}
if runtime.Changed("offset-col") {
offset["col_offset"] = runtime.Int("offset-col")
}
props["offset"] = offset
}
if runtime.Changed("z-index") {
props["z_index"] = runtime.Int("z-index")
}
return props, nil
}
func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isHighRisk bool) common.Shortcut {
risk := "write"
if isHighRisk {
risk = "high-risk-write"
}
flags := flagsFor(command)
return common.Shortcut{
Service: "sheets",
Command: command,
Description: description,
Risk: risk,
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flags,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID := strings.TrimSpace(runtime.Str("sheet-id"))
sheetName := strings.TrimSpace(runtime.Str("sheet-name"))
// uploadedImageToken="": Validate never uploads; floatImageProperties
// still validates the --image path and the source XOR.
_, err = floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, "")
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, "")
// With a local --image, Execute first uploads the file; surface that
// extra step in the preview (mirrors +cells-set-image's dry-run).
if img := strings.TrimSpace(runtime.Str("image")); img != "" {
manageBody, _ := buildToolBody("manage_float_image_object", input)
return common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/upload_all").
Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")").
Body(map[string]interface{}{
"file_name": floatImageName(runtime),
"parent_type": sheetMediaParentType(token),
"parent_node": token,
"size": "<file_size>",
"file": "@" + img,
}).
POST(toolInvokePath(token, ToolKindWrite)).
Desc("create float image referencing the uploaded file_token").
Body(manageBody)
}
return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
// If a local --image was given, upload it first (parent_type=
// sheet_image) and embed the returned file_token; otherwise this
// returns "" and the token/uri flags are used as-is.
uploadedImageToken, err := uploadFloatImageIfLocal(runtime, token)
if err != nil {
return err
}
input, err := floatImageWriteInput(runtime, token, sheetID, sheetName, op, withIDFlag, uploadedImageToken)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_float_image_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
}
// uploadFloatImageIfLocal uploads a local --image (when set) as a sheet_image
// and returns its file_token. Returns ("", nil) when --image is not set (the
// token/uri source flags are used instead, e.g. on +float-image-update which
// does not register --image).
func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
img := strings.TrimSpace(runtime.Str("image"))
if img == "" {
return "", nil
}
info, err := runtime.FileIO().Stat(img)
if err != nil {
return "", sheetsInputStatError("image", err)
}
return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size())
}
func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if withIDFlag && strings.TrimSpace(runtime.Str("float-image-id")) == "" {
return nil, sheetsValidationForFlag("float-image-id", "--float-image-id is required")
}
props, err := floatImageProperties(runtime, uploadedImageToken, op == "create")
if err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"operation": op,
"properties": props,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if withIDFlag {
input["float_image_id"] = strings.TrimSpace(runtime.Str("float-image-id"))
}
return input, nil
}
var FloatImageCreate = newFloatImageWriteShortcut(
"+float-image-create",
"Create a floating image (from a local --image path, or an existing --image-token / --image-uri).",
"create", false, false,
)
var FloatImageUpdate = newFloatImageWriteShortcut(
"+float-image-update",
"Update an existing floating image (target by --float-image-id; provide the full set of flat flags).",
"update", true, false,
)
// FloatImageDelete uses the standard CRUD delete factory since it only
// needs --float-image-id + --yes.
var floatImageDeleteSpec = objectCRUDSpec{
commandPrefix: "+float-image",
toolName: "manage_float_image_object",
idFlag: "float-image-id",
idField: "float_image_id",
}
var FloatImageDelete = newObjectDeleteShortcut(floatImageDeleteSpec)
// filter view — cli_status: cli-only but the tool is in mcp-tools.json so
// it dispatches via the same One-OpenAPI endpoint as every other shortcut.
// --view-name and --range are hoisted out of properties (optional on both
// create and update; they always win over properties.{view_name, range}).
var filterViewEnhance = func(rt flagView, input map[string]interface{}) {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return
}
if v := strings.TrimSpace(rt.Str("range")); v != "" {
props["range"] = v
}
if v := strings.TrimSpace(rt.Str("view-name")); v != "" {
props["view_name"] = v
}
}
var filterViewSpec = objectCRUDSpec{
commandPrefix: "+filter-view",
toolName: "manage_filter_view_object",
idFlag: "view-id",
idField: "view_id",
enhanceCreateInput: filterViewEnhance,
enhanceUpdateInput: filterViewEnhance,
}
var FilterViewCreate = newObjectCreateShortcut(filterViewSpec)
var FilterViewUpdate = newObjectUpdateShortcut(filterViewSpec)
var FilterViewDelete = newObjectDeleteShortcut(filterViewSpec)
// ─── filter (sheet-scoped, no separate filter_id) ─────────────────────
//
// At most one filter per sheet, so filter_id is implicit (the tool treats
// filter_id and sheet_id as the same value). create requires --range
// (covering the header) and an optional --data with conditions; update
// patches conditions / range; delete drops the entire filter.
// FilterCreate creates a sheet-level filter. --range covers the data
// (header inclusive). --data is optional — empty filter is valid.
var FilterCreate = common.Shortcut{
Service: "sheets",
Command: "+filter-create",
Description: "Create a sheet-level filter (one per sheet).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+filter-create"),
Validate: validateViaInput(filterCreateInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := filterCreateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := filterCreateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func filterCreateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, sheetsValidationForFlag("range", "--range is required")
}
props := map[string]interface{}{
"range": strings.TrimSpace(runtime.Str("range")),
}
if runtime.Str("properties") != "" {
extra, err := requireJSONObject(runtime, "properties")
if err != nil {
return nil, err
}
for k, v := range extra {
if k == "range" {
continue // --range wins
}
props[k] = v
}
}
input := map[string]interface{}{
"excel_id": token,
"operation": "create",
"properties": props,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
return input, nil
}
// FilterUpdate patches the sheet-level filter. --properties carries the
// rules; --range is first-class and overrides any properties.range.
// filter_id is implicit (sheet-scoped).
var FilterUpdate = common.Shortcut{
Service: "sheets",
Command: "+filter-update",
Description: "Update the sheet-level filter (overwrite rules + range).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+filter-update"),
Validate: validateViaInput(filterUpdateInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := filterUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := filterUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func filterUpdateInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if sheetID == "" {
return nil, sheetsValidationForFlag("sheet-id", "+filter-update requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
}
if strings.TrimSpace(runtime.Str("range")) == "" {
return nil, sheetsValidationForFlag("range", "--range is required")
}
props, err := requireJSONObject(runtime, "properties")
if err != nil {
return nil, err
}
// --range wins over any properties.range
props["range"] = strings.TrimSpace(runtime.Str("range"))
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"filter_id": sheetID, // server contract: filter_id === sheet_id for sheet-scoped filters
"properties": props,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
return input, nil
}
// FilterDelete drops the sheet-level filter entirely. high-risk-write.
var FilterDelete = common.Shortcut{
Service: "sheets",
Command: "+filter-delete",
Description: "Remove the sheet-level filter (irreversible).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+filter-delete"),
Validate: validateViaInput(filterDeleteInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := filterDeleteInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := filterDeleteInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_filter_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// filterDeleteInput mirrors the standalone +filter-delete body for batch
// sub-op reuse. Server contract: filter_id === sheet_id, and update/delete
// must populate filter_id (per manage_filter_object schema). The CLI has no
// separate --filter-id flag because the value is fully derived from sheet_id;
// only --sheet-id is accepted (not --sheet-name, since there's no mid-call
// network lookup to resolve it).
func filterDeleteInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if sheetID == "" {
return nil, sheetsValidationForFlag("sheet-id", "+filter-delete requires --sheet-id (filter_id must equal sheet_id; --sheet-name needs a network lookup unavailable here — call +workbook-info first or pass --sheet-id directly)")
}
input := map[string]interface{}{
"excel_id": token,
"operation": "delete",
"filter_id": sheetID, // server contract: filter_id === sheet_id
}
sheetSelectorForToolInput(input, sheetID, sheetName)
return input, nil
}