mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
bd898a1d74
* feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts
* docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:
- write-cells: sanity-check units / dimension conversion / quantity factors
before formula writes (formulas can run clean yet be off by a factor);
keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
update" is not explicitly requested; scope rewrite/transform precisely so
rows/columns that should stay unchanged are kept 1:1; treat header-stated
format rules as checklist items; confirm the artifact file actually exists
before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
display (scientific notation collapses distinct numbers and causes false
duplicates)
- chart: format date/number axes via source-cell number_format; place charts
outside the data area so they do not cover existing data
* feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
read-data / workbook / chart updates)
* docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.
- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
(or +workbook-create --sheets when the workbook does not exist yet);
plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
of the create-empty-then-table-put two-step
Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
* docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
* docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
* feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
* refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
* docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
shrink the widths of existing columns (any blanket resize, even by 1px,
breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
a percentage (pie, or stack.percentage on bar/column) rather than raw counts
* docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
* docs: add lark sheets financial modeling guidance
* docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
* docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
* docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
* docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:
1. chart: label position.row as 0-based (first row = row:0), distinct
from the 1-based row numbers used by A1 ranges and +dim-insert
--position, removing the row-base ambiguity.
2. chart: convert the three runnable examples whose JSON contains a
quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
--properties '{...}' to a stdin heredoc (--properties - <<'JSON').
Inside an inline single-quoted string bash strips the inner quotes
around the sheet name (and splits names with spaces into words),
corrupting the JSON; a quoted heredoc delimiter performs no shell
substitution and preserves it. Adds a short note on the pitfall.
3. filter / filter-view: add the full conditions[].type x compare_type
enum table (text / number / multiValue / color and their respective
compare_type values and values shape), and call out the
equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
previously only showed two values via examples.
* docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
* fix(sheets): regenerate flag defs and fix asasalint in table io
* feat(sheets): add counta to chart aggregateType enum
Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.
* feat(sheets): make --target-position and --range mutually exclusive on +pivot-create
Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".
Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.
* feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
* fix(sheets): add mention_type enum to set_cell_range cells schema
Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").
- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)
* feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create
Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.
isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.
* feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create
Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.
* docs(lark-sheets): remove financial modeling standards reference
Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.
* docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references
Synced from sheet-skill-spec.
- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.
* feat(sheets): add --styles to +table-put for one-step typed write with styling
+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.
Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).
Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).
* docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification
skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.
Synced from sheet-skill-spec.
* feat(sheets): document link requirement for @document mentions in cells flag schema
@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.
* fix(sheets): reject cond-format attrs whose shape mismatches rule_type
A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).
The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.
Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.
* test(sheets): guard condFormatAttrsRequired against flag-schemas drift
Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.
* fix(sheets): default +table-get to full used range, not A1 current region
+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).
- Probe the used range over the full physical grid (row_count × column_count
from the workbook structure) so it spans internal blank rows/columns; fall
back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.
* docs(sheets): fix csv-get current_region guidance to cross-check row_count
current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.
- current_region: warn it can be both smaller (internal blank rows truncate)
and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
(= actual_range), not the physical sheet size; has_more only reflects the
current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
physical row_count and probe past current_region's last row for data beyond an
internal blank row.
* fix(sheets): collapse duplicate validateCreateInput from bad merge resolution
A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).
Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.
* refactor(sheets): migrate legacy error helpers to typed errs in sheets domain
golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.
- Replace every common.FlagErrorf with common.ValidationErrorf (typed
*errs.ValidationError, same signature) across workbook / table_io /
dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
unchanged (re-wrapping would downgrade classification) and attaches the
failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
layer re-wraps into typed validation errors (buildTypedCell / Arrow
decode-encode) or surfaces as a partial_success message string
(writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
with that reason, per the lint guidance.
No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.
* fix(shortcuts): clarify single-stdin constraint in flag help and error hint
Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.
- Reword the generated help hint to '- reads stdin (one flag per call;
use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.
* feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000
放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。
Synced from sheet-skill-spec@93f7a78.
* docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签
源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。
* docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引
源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。
* feat(sheets): 写操作报错增强 + --token 别名
- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动
* docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导
* fix(sheets): migrate +table-put to typed error contract
The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:
- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
carrying written_sheets, returning the partial-failure exit signal
Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)
* docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段)
* feat(sheets): styles 接受 halign/valign 等对齐字段别名
把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。
* feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.
Mirror the existing slides/doc/drive two-stage pattern:
- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
The wiki:node:read scope is enforced on this path only, so non-wiki
invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
the node_token through unchanged.
All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.
Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).
* docs(sheets): note --url accepts wiki URLs (synced from spec)
* fix(sheets): match --url path segment via url.Parse, not substring
parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.
* fix(sheets): satisfy errorlint/copyloopvar + regen flag defs
- helpers_test.go: drop the Go 1.22+ redundant `tc := tc` loop copy
(copyloopvar).
- lark_sheet_dataframe.go, lark_sheet_table_io.go: switch the
intermediate-error fmt.Errorf calls from %v to %w so errorlint passes.
Behavior unchanged — these errors are always rewrapped into typed
validation errors at the command layer.
- flag_defs_gen.go: regenerate from data/flag-defs.json (drift from the
wiki-URL merge).
* ci: allow Apache Arrow module in license check
Arrow is Apache-2.0 overall, but it vendors c-ares (LicenseRef-C-Ares,
ISC-like) inside the module which go-licenses classifies as Unknown and
the strict disallowed_types=...,unknown gate rejects.
Pass --ignore github.com/apache/arrow/go/v17 since Arrow is required by
sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC
ingest) and the vendored c-ares is not redistributed by us.
* fix(sheets): resolve wiki URL in +range-move/+range-copy Execute
transformExecuteFn (the named Execute helper shared by +range-move and +range-copy) still called the network-free resolveSpreadsheetToken, so a /wiki/ URL reached transform_range as an unresolved node_token and failed. #1519's sweep over Execute hooks only rewrote inline closures; this is the only Execute backed by a named helper. Switch it to resolveSpreadsheetTokenExec (Validate/DryRun stay network-free) and add a +range-move wiki-URL regression test.
* refactor(sheets): drop +table-put manual capacity grow; rely on set_cell_range auto-grow
set_cell_range now auto-grows the sub-sheet to fit the write, so the
ensureSheetCapacity helper (and its modify_sheet_structure dim-insert
call before each write) is no longer needed. This also closes a data-
safety hole flagged in review: inserting before the last existing row
could push real data down into the area set_cell_range was about to
write, and allow_overwrite=false could not protect against it because
the structural insert had already mutated the sheet by the time the
write-collision check ran.
Verified end-to-end against a real spreadsheet: +table-put writing
300x25 into a fresh Sheet1 (default 200x20) succeeds in one write and
the sheet ends up 301x25.
* fix(sheets): close --dataframe stdin guard hole
--dataframe is binary and bypasses the common Input resolver, which is
where the existing single-stdin guard lives. Result: an invocation like
+table-put --dataframe - --styles - was accepted, then one of the two
consumers raced for stdin and the other silently saw an empty stream.
Add a stdinConsumed marker on RuntimeContext that both consumers share:
common.resolveInputFlags sets it when an Input flag uses '-', and
readDataframeBytes both checks and sets it. A second consumer is
rejected up front with an actionable hint pointing at @file.
Flagged in code review (lark_sheet_dataframe.go:93).
* fix(sheets): harden +table-put / +table-get input validation and round-trip safety
Four review-flagged correctness gaps in table I/O, all bundled because
they touch the same file:
1. --sheets accepted trailing data after the first JSON value
(json.Decoder does not surface that, unlike json.Unmarshal). A new
decoderExpectEOF helper rejects e.g. `--sheets '{...} oops'` with a
typed validation error instead of letting the leading object pass
through and surface as a confusing downstream failure.
2. +table-get with a duplicate header (e.g. `amount, amount`) used to
read back successfully — the dtypes map silently collapsed to one
entry — and only failed later on +table-put because the writer
rejects duplicate column names. Fail fast at read time with an
actionable hint to rename or pass --no-header. --no-header mode is
exempt (fallback col<N> names are always unique).
3. +table-put dry-run rendered an invalid range like A1:C0 when
header=false with rows=[]. tablePutFullRange returns "" for an
empty matrix or zero columns instead of building a degenerate
rectangle.
4. +table-get with --sheet-id and a get_workbook_structure miss (read
failure or selector mismatch) used to return a target with
name="", which then broke +table-get → +table-put round-trip (the
writer requires a non-empty sheet name). Fall back to using the id
as the name.
End-to-end verified against a real spreadsheet: trailing data, duplicate
header, and --no-header fallback all behave as advertised.
* fix(sheets): apply +workbook-create style-only ops instead of silently dropping them
A +workbook-create call carrying only cell_merges / row_sizes / col_sizes
(no --values / --sheets and no cell_styles) used to create the workbook
but silently drop the requested visual ops. Two reasons, both fixed:
- workbookCreateStyleDimensions only counted cell_styles when computing
the write extent, so cell_merges / row_sizes / col_sizes always
contributed 0 → buildValuesPayload returned a nil payload → Execute
skipped writeTypedSheets entirely → no visual ops ran. Extend the
helper to fold the merge / resize ranges in.
- Pure row_sizes / col_sizes payloads can never expand a cell rectangle
(they are dimension ranges, not cell ranges), so even with the extent
fix Execute would still skip the write path. Add a no-data branch:
when payload == nil but a styles item is present, look up the default
sheet and apply visual ops directly via applyWorkbookCreateVisualOps.
The dry-run plan mirrors this so the preview shows the visual ops.
Also picks up the --values trailing-JSON-data EOF check (mirror of the
--sheets one in lark_sheet_table_io.go).
End-to-end verified against a real spreadsheet: a cell_merges-only
+workbook-create now produces a sheet with merged_cells_count: 1.
* fix(sheets): preserve causes and render messages cleanly for typed validation errors
common.ValidationErrorf goes through fmt.Sprintf, which does not support
%w — the seven call sites that used `%w` were rendering the cause as
literal `%!w(*fmt.wrapError=&{...})` and dropping the cause from the
typed-error chain (so callers couldn't errors.As back to the underlying
error).
Switch each to `%v` for clean rendering and attach the cause via
.WithCause(err) so the typed contract is preserved. Touched call sites:
- lark_sheet_dataframe.go: --dataframe Arrow decode / stdin read / file
read failures (3 call sites).
- lark_sheet_table_io.go: --sheets invalid JSON, payload-validate
per-cell coercion error, buildSheetMatrix per-cell error,
--dataframe-out arrow encode failure (4 call sites).
End-to-end verified against a real spreadsheet: both invalid-JSON and
typed-cell errors now render readable messages instead of %!w(...).
* sync(sheets): pick up +sheet-{show,hide}-gridline in +batch-update schema
Mirror of the sheet-skill-spec change adding the two gridline shortcuts
to cli-schemas.json batch_update.operations.shortcut enum. Synced from
the upstream canonical via generate:cli + sync:cli.
Verified end-to-end on a real spreadsheet — +batch-update with a
+sheet-hide-gridline op passes schema validation and the backend run
returns succeeded: 1.
* sync(sheets): pick up +workbook-export UX clarification from spec
Mirror of the sheet-skill-spec update that documents +workbook-export's
default-no-download behavior and its relationship to drive +export
--doc-type sheet. Synced from canonical via generate:cli + sync:cli +
go generate.
End-to-end verified against a real spreadsheet:
- Omit --output-path → ok:true, downloaded:false, file_token returned
- Pass --output-path ./crfix_test.xlsx → ok:true, file saved
(17892 bytes), saved_path returned
The --help output for +workbook-export now states the default behavior
and points callers at `drive +export --doc-type sheet` when they need
the --output-dir / --file-name / --overwrite split.
* test(sheets): assert typed errs.Problem instead of err.Error() substrings
Per the coding guideline "Error-path tests must assert typed metadata via
errs.ProblemOf (category / subtype / param) and cause preservation, not
message substrings alone." — sweep through every error-path assertion in
the sheets domain and replace the
`strings.Contains(stdout+stderr+err.Error(), ...)` pattern with two
small helpers landed in helpers_test.go:
requireProblem(t, err, wantCategory, wantSubtype, msgContains)
-> *errs.Problem
requireValidation(t, err, msgContains)
-> *errs.ValidationError // shorthand for CategoryValidation +
// SubtypeInvalidArgument; lets callers
// also assert .Param / .Params / .Cause
~60 assertion sites across 18 test files now check the typed envelope
shape, with message-substring checks moved onto the returned Problem
(.Message / .Hint / .Param). The substring is preserved as a sanity
check rather than the sole assertion, so a category drift like
validation → internal would now fail loudly instead of slipping past.
Cases intentionally left as substring (each with a one-line reason):
- Errors that come straight from cobra's native flag parser (untyped
*errors.errorString — e.g. "required flag(s) ... not set", mutually-
exclusive groups). Re-typing these needs a custom FlagErrorFunc and
is out of scope here.
- Intermediate errors from decodeArrowToSheet that the caller wraps
into a typed envelope (`//nolint:forbidigo` reason). Those unit
tests assert the unwrapped intermediate directly.
One production tweak:
- shortcuts/sheets/flag_schema.go: printFlagSchemaFor returns typed
*errs.ValidationError (with WithParam("--flag-name") on the
unknown-flag branch) instead of raw fmt.Errorf. The framework
already wraps this when called via --print-schema, so user-facing
behaviour is unchanged; direct callers (and tests) now get the
typed envelope.
Verified: go test ./shortcuts/sheets/... passes; golangci-lint
--new-from-rev=origin/main reports 0 issues.
* test(common): assert typed errs.Problem instead of err.Error() substrings
Mirror of the sweep just landed in shortcuts/sheets: replace error-path
substring assertions with typed-envelope checks via two small helpers
landed in a new shortcuts/common/typed_error_assertions_test.go:
requireProblem(t, err, wantCategory, wantSubtype, msgContains)
-> *errs.Problem
requireValidation(t, err, msgContains)
-> *errs.ValidationError // shorthand for CategoryValidation +
// SubtypeInvalidArgument; lets callers
// also assert .Param / .Params / .Cause
8 sites moved to typed assertions across runner_jq_test.go,
mcp_client_test.go, drive_media_upload_typed_test.go, and
runner_input_test.go (the input tests already used a typed-param helper;
this just retargets the substring follow-up onto the typed Message).
Sites intentionally left as substring + comment (production returns raw
fmt.Errorf, not a typed envelope):
- runner_botinfo_test.go (6 sites): BotInfo / fetchBotInfo wrap upstream
errors with fmt.Errorf so the SDK-level message ([99991], 403,
invalid character, etc.) shows through.
- runner_args_test.go (4 sites in 2 tests): rejectPositionalArgs returns
raw fmt.Errorf to satisfy cobra's PositionalArgs contract.
- permission_grant_test.go (2 sites): assert on stderr / hint strings,
not error messages — already out of the err.Error() substring class.
No production code changes.
Verified: go test ./shortcuts/common/... passes;
golangci-lint --new-from-rev=origin/main ./shortcuts/common/... reports
0 issues.
* fix(sheets): plug four +table-put / +table-get correctness gaps flagged in CR
Four review-flagged bugs, all in lark_sheet_table_io.go (bundled because
they touch the same file and the same +table-put / +table-get domain):
1. +table-get --dry-run dropped the --sheet-id / --sheet-name selector
from the get_cell_ranges body, while Execute always passed it. Agents
that validate the dry-run shape and then run live would see a request
shape mismatch. The dry-run now calls sheetSelectorForToolInput so
the body matches Execute.
2. isDateNumberFormat used a simple `strings.ContainsRune(_, 'y')` so
number formats like "JPY #,##0" (a currency prefix that happens to
contain a lone 'Y') were misread as date formats — round-tripping
integer cells out as ISO dates. The detector is now token-aware:
it skips quoted "...", `\\x`-escaped, and `[...]` bracket sections,
and only fires on an unescaped `yy` (a real Excel year token).
3. sheetCreateDims sized new append-mode sheets by `headerOn(s)` only,
but writeSheetData forces a header on empty append sheets when
Header == nil. Near 50000 rows / 200 cols this created the sheet one
row short and the follow-up set_cell_range bounced off the backend
ceiling. Size now matches the forced-header logic exactly.
4. tableGetTargets fallback paths (read-failure / selector mismatch on
--sheet-id) returned a target with name="" — already corrected for
--sheet-id structure-success path in 086876d2, but the structure-
failure fallback still left it empty. Use the id as the name there
too so the +table-get → +table-put round-trip never breaks on a
nameless sheet.
End-to-end verified against a real spreadsheet:
- table-get --dry-run with --sheet-name / --sheet-id both render the
selector field in the get_cell_ranges body
- A real round-trip (typed put → get) preserves dtypes + formats
* fix(sheets): bound --dataframe memory use with byte / row / column caps
readDataframeBytes used to read the whole Arrow file unbounded — a
stdin / file > 1 GiB would OOM the CLI long before the backend
per-sheet ceilings kicked in. decodeArrowToSheet then materialized
every record into [][]interface{} regardless of size.
Three caps now match the backend's per-sheet hard ceilings:
- byte cap: 256 MiB (covers worst-case 200×50000 cells × ~25 B Arrow
overhead). File path pre-Stat()s before opening; both file and stdin
paths read through io.LimitReader so an oversized input is rejected
without allocating the full payload.
- column cap: 200, checked at schema-decode time before allocating any
per-column slices.
- row cap: 50000, checked during record-batch iteration so a 1M-row
Arrow file is rejected mid-stream instead of fully decoding first.
End-to-end verified against PPE — a 257 MiB file is rejected at file-
Stat with a typed validation error before any read happens.
* fix(drive): wrap +export ctx cancellation/deadline as typed errs.NetworkError
The poll loop in RunExport returned ctx.Err() directly in two places —
on the inter-attempt sleep cancel and on the pre-attempt deadline check.
That let context.Canceled / context.DeadlineExceeded escape as untyped
errors at the cobra layer, bypassing the typed-error contract every
other failure path already honors.
Add wrapExportContextErr that maps both into errs.NewNetworkError with
SubtypeNetworkTransport / SubtypeNetworkTimeout respectively and
preserves the cause via .WithCause(err), so callers can still
errors.Is(err, context.Canceled) downstream.
CR-flagged at drive_export.go:229 / :234.
* ci(license): narrow Apache Arrow workaround with a follow-up assertion
The dependency-license check still has to --ignore Apache Arrow wholesale
because go-licenses' classifier parses its LICENSE.txt as a single license
and mis-reports the module as LicenseRef-C-Ares / Unknown (Arrow inlines
the c-ares 3rdparty notice alongside its own Apache-2.0). Re-classifying
on our side isn't possible without changing go-licenses itself.
The CR concern was that --ignore is too wide — a future Arrow re-license
or new inlined dep would silently sail through. Add a follow-up step that
re-checks Arrow's LICENSE.txt independently: it must still open with
"Apache License" AND must still inline the c-ares 3rdparty notice (the
two facts that make the --ignore safe today). If either invariant breaks,
CI fails here and forces a human to re-evaluate the ignore.
Verified locally — both assertions pass against the current pinned
Arrow v17.
* sync(sheets): pick up +table-put payload-shape doc corrections from spec
Mirror of the sheet-skill-spec change that fixes three places teaching
an invalid +table-put payload shape — the typed protocol only has
columns / data / dtypes / formats (no formula field) and must always
be wrapped in an outer {"sheets":[...]} envelope. write-cells and the
SKILL.md decision table previously used the wrong field names (type /
format) and pointed users at +table-put for formula writes, which the
shortcut can't actually accept.
Synced from upstream canonical via generate:cli + sync:cli.
* test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create
AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E
for new flows. Three new files cover the four shortcuts this branch
adds or materially changes:
- sheets_gridline_dryrun_test.go — pins +sheet-show-gridline /
+sheet-hide-gridline as a single modify_workbook_structure call with
the right operation name (show_gridline / hide_gridline) and
sheet_id, so an op-name typo would trip CI before any live run.
- sheets_workbook_import_dryrun_test.go — pins +workbook-import as a
two-step plan (drive media upload + drive import-task create) with
the doc type hard-coded to "sheet" — the wrapper's whole reason for
existing on top of generic drive +import. --name reaches file_name
on the wire; file_extension is sniffed from the local file.
- sheets_table_put_typed_workflow_test.go — two live workflows running
against a freshly created spreadsheet. The first runs the full
typed +table-put → +table-get round-trip (date / numeric / object
columns with custom number_format) and asserts the dtype + format
contract holds end-to-end. The second exercises the typed
+workbook-create --sheets path: create + write in one shortcut, the
payload sheet name adopts the workbook's default sheet (no empty
"Sheet1" left behind), and the typed contract still survives the
read-back.
End-to-end verified locally (user identity): typed put round-trips
preserve dtypes (date → datetime64[ns], numeric → float64, object →
object) + formats verbatim; workbook-create adopts the named sheet as
the first sheet with the same typed shape intact.
* sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec
Mirror of the sheet-skill-spec change that adds a DataFrame ↔ JSON
bridge as a skill-bundled Python script instead of inside the CLI
binary. Per PR #1355 review (docx NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, §4.2):
keep the CLI a thin JSON/REST client; pandas / Arrow editing lives in
the caller's Python process. Synced from canonical via generate:cli +
sync:cli.
- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
one sheet, .parquet / .feather / .arrow / .csv / .json. Shells out to
`+table-put` / `+table-get` over typed JSON — no CLI changes.
- SKILL.md decision tree + write-cells.md +table-put section: explicit
pointers so pandas users land on the script instead of hand-rolling
the `--sheets` payload.
End-to-end verified against PPE: 3-row DataFrame (datetime / float /
object) round-trips parquet → script put → real sheet → script get →
parquet with dtypes preserved.
* Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"
This reverts commit 2964983b92.
* sync(sheets): pick up sheets_df.py + doc DRY cleanup from spec
Mirror of the sheet-skill-spec change that ships a 32-line helper-only
sheets_df.py (df_to_sheet + sheet_to_df) and removes the corresponding
inline `def` blocks from three reference docs.
- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
one +table-put / +table-get sheet, importable as a library. Same
helper pair the docs already taught, lifted out of the prose so
callers can `from sheets_df import df_to_sheet, sheet_to_df`.
- lark-sheets-write-cells.md / lark-sheets-read-data.md /
lark-sheets-workbook.md: drop the inline helper definitions; keep
the usage examples (single/multi-sheet, round-trip) and switch them
to import-from-script. workbook reference's +workbook-create
--sheets section now points pandas users at the helper directly
(was previously a textual reference back to write-cells).
End-to-end verified against PPE (--as user):
- +workbook-create with df_to_sheet for three sheets (income / balance
/ cashflow): create ok, dtypes (datetime64[ns] / float64) + formats
(#,##0 / 0.0% / yyyy-mm-dd) survive on read-back through sheet_to_df.
- read → pandas mutate → write-back round-trip preserves both data
and formats.
* chore: drop accidentally-committed __pycache__/ and gitignore .pyc
The previous commit (5fac9c39) shipped sheets_df.py and inadvertently
included its `__pycache__/sheets_df.cpython-312.pyc` — local Python
import created the bytecode cache during PPE round-trip verification and
`git add skills/lark-sheets/` swept it in.
Remove the pyc and add Python bytecode patterns to .gitignore so the
skill-bundled helper scripts don't pull cache files into future commits.
* refactor(sheets): drop --dataframe / --dataframe-out + apache/arrow dep
Per the design review at NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, the Arrow IPC binary
input/output channel adds a heavy columnar runtime to the CLI for no new
capability — the typed JSON --sheets path already covers everything, and
the column-major / zero-copy advantages collapse the moment the CLI re-
encodes into the row-oriented sheets OpenAPI JSON body. Removing it also
lets us drop the `--ignore github.com/apache/arrow/go/v17` license-check
escape hatch.
Deleted:
- shortcuts/sheets/lark_sheet_dataframe.go (+ test)
- --dataframe branches in +table-put / +workbook-create
- --dataframe-out branch in +table-get
- StdinConsumed / MarkStdinConsumed exported methods (the binary stdin
reader was the only out-of-band consumer); internal stdinConsumed
guard against duplicate `-` input flags stays
- apache/arrow/go/v17 + transitive deps via `go mod tidy`
- CI go-licenses --ignore for arrow and the LICENSE.txt assertion step
- --dataframe / --dataframe-out coverage in skill references
Pandas users keep the round-trip via the existing skill script
skills/lark-sheets/scripts/sheets_df.py over the JSON path.
The full pre-removal state is preserved on branch feat/sheets-arrow-stash.
Upstream sheet-skill-spec follow-up: the two flag rows in the canonical
spec + base table tblV2F6fqIjyCFQW must also be dropped so the next sync
does not re-add them.
* sync(sheets): pick up --sheets one-liner fix from spec
Mirrors sheet-skill-spec 5562f83. The +table-put / +workbook-create
--sheets flag descriptions (and the --print-schema description on the
sheets array) now point at the existing df_to_sheet helper instead of
the previous misleading one-liner that produced a dict missing the
outer {"sheets":[...]} envelope and the per-sheet `name`. Agents that
copy-paste the description verbatim now build a valid payload.
Auto-synced via spec's generate:cli + sync:consumers; go generate
./shortcuts/sheets/... regenerated flag_defs_gen.go so its embedded
flagDefs stays byte-equal to data/flag-defs.json.
* test(sheets/e2e): close E2E coverage gaps for newly added shortcuts
AGENTS.md requires both dry-run and live E2E for every newly registered
shortcut, and behavior-changing refactors need at least the matching
half. Three gaps remained on feat/lark-sheets-develop:
- +sheet-show-gridline / +sheet-hide-gridline (new): only dry-run E2E.
Add sheets_gridline_workflow_test.go — create a real spreadsheet,
toggle hide then show against a live sub-sheet, assert ok=true on
both (gridline state is write-only — there is no read-back field on
+sheet-info / +workbook-info — so a successful envelope is the
meaningful signal; the dry-run E2E already pins the wire shape).
- +workbook-import (new): only dry-run E2E. Add
sheets_workbook_import_workflow_test.go — write a local CSV, run
the full upload → create-task → poll, assert ready=true with a
sheet token, +info confirms the imported workbook is reachable,
cleanup deletes the spreadsheet.
- +workbook-export refactor (no-download default changed): had live
E2E but no dry-run E2E in tests/cli_e2e/. Add
sheets_workbook_export_dryrun_test.go — pin the three sheet-
specific differences vs drive +export: type=sheet hard-coded,
csv mode routes --sheet-id onto sub_id (xlsx mode omits it), and
--output-path maps onto the dry-run plan's top-level output_dir.
Also pins the csv-without-sheet-id validation error.
* refactor(sheets): unify workbookCreatedButFillFailed with OutPartialFailure
Three "made it halfway and stopped" exits in the sheets domain previously
disagreed on shape, which made the post-failure recovery flow hard for
agents to predict from one command to another:
- +table-put partial write → exit 1, stdout ok:false envelope
- +table-put zero-sheet write → exit 1, stderr api/server_error
- +workbook-create create-but-fill → exit 2, stderr validation/failed_precondition
OutPartialFailure exists exactly for "the side effect landed but the
follow-up didn't" — it stamps an ok:false result envelope on stdout
(carrying the state the caller needs to recover) and returns the bare
partial-failure exit signal. The workbook-create fill-failure path was
the odd one out: it surfaced as a typed failed_precondition error on
stderr, which agents couldn't tell apart from a plain validation refusal
even though the spreadsheet really did exist and a retry / cleanup was
possible.
Migrate workbookCreatedButFillFailed onto OutPartialFailure so the four
call sites in +workbook-create's Execute (sheet-resolve failure, initial
fill failure, style-only resolve failure, style-only apply failure) emit
the same envelope shape +table-put's partial write does:
{
"ok": false,
"data": {
"spreadsheet_token": "shtNEW",
"reason": "spreadsheet shtNEW created but initial fill failed",
"hint": "the spreadsheet exists; retry the fill … or delete it",
"cause": {"category": "...", "subtype": "...", "message": "..."}
}
}
The inner failure's typed problem (category / subtype / message) is
flattened into the `cause` field so agents stay diagnosable from the JSON
envelope alone, instead of having to errors.Unwrap a Go error.
Updated TestExecute_WorkbookCreate_FillFailureKeepsToken to assert the
new shape (ok:false envelope on stdout, *output.PartialFailureError exit
signal, structured cause carrying the underlying invalid_response
subtype) — preserving the original test intent (token must survive for
recovery; inner cause must stay diagnosable) under the new contract.
* chore(sheets): three review nits — WithCause + stale comment + unexport
- shortcuts/sheets/flag_schema_validate.go:106 — composite-JSON shape
validation was wrapping vErr's message into a typed sheets validation
error without preserving vErr as the typed cause; add the missing
.WithCause(vErr) so errors.Unwrap and ProblemOf still find the
underlying validator error (matches every other typed-error chain
helper in the file).
- shortcuts/sheets/lark_sheet_batch_update.go:92 — comment claimed
batchUpdateInput returns "FlagErrorf-typed errors", but FlagErrorf no
longer exists (the typed-error migration replaced it with
common.ValidationErrorf / errs.ValidationError); update the comment
to reflect what is actually returned.
- shortcuts/drive/drive_export.go:121 — drop the ValidateExport public
alias and rename to validateExport. sheets +workbook-export reuses
RunExport / PlanExportDryRun from this package but inlines its own
(sheet-specific) Validate, so there is no cross-package call site —
ValidateExport was a misleading sibling of the genuinely-shared
ValidateImport. Comment added to record the asymmetry so future
readers do not export it back.
* chore(deps): drop stale indirect bumps left by the arrow removal
The earlier --dataframe / --dataframe-out + apache/arrow/go/v17 removal
deleted the arrow consumer but left two indirect lines in go.mod pinned
to the versions arrow had pulled in:
- github.com/kr/text v0.2.0
- golang.org/x/exp v0.0.0-20240222234643-814bf88cf225
With arrow gone, larksuite/cli was the only requirer of those exact
versions; every real consumer needs lower ones (kr/pretty wants
kr/text v0.1.0; charmbracelet/huh wants x/exp …20231006; xo/terminfo
wants x/exp …20220909). Removing the two indirect lines and running
`go mod tidy` lets MVS pick the real-consumer versions and drops the
explicit indirect entries entirely — go.mod net-diff against main is
now zero for this branch.
Verified locally: go build ./...; go test ./shortcuts/sheets/...
./shortcuts/drive/... ./shortcuts/common/... ./internal/auth/...
./cmd/auth/... — all green.
---------
Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
Co-authored-by: Chenweifeng-bd <chenweifeng.1534@bytedance.com>
1214 lines
48 KiB
Go
1214 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=sheet_image)").
|
||
Body(map[string]interface{}{
|
||
"file_name": floatImageName(runtime),
|
||
"parent_type": "sheet_image",
|
||
"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 common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
|
||
FilePath: img,
|
||
FileName: floatImageName(runtime),
|
||
FileSize: info.Size(),
|
||
ParentType: "sheet_image",
|
||
ParentNode: &spreadsheetToken,
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|