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
1208 lines
48 KiB
Go
1208 lines
48 KiB
Go
// 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 2–3, 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
|
||
}
|