mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(sheets): spec-driven shortcut refactor with backward-compatible package (#1220)
* refactor(sheets): rebuild lark-sheets on sheet-skill-spec canonical + One-OpenAPI
Restart lark-sheets as a spec-driven downstream. Skill content (SKILL.md
and 16 references covering 13 operations skills + 3 workflow skills,
including the standalone filter-view skill) is mirrored from the
sheet-skill-spec canonical-spec; do not hand-edit, change upstream and
rerun npm run sync:consumers.
Drop the 11 legacy shortcut sources (spreadsheet / sheet management,
cell ops, dropdown, filter-view, float image, etc.) and 10 associated
tests. Wire up the new sheet_ai/v2 One-OpenAPI single entry that
dispatches by tool_name with JSON-string input/output, and land the
first canonical shortcut +workbook-info as a template that exercises
the public token XOR pair, Risk tiering, and zero-side-effect DryRun.
sheet_ai_api.go provides callTool / invokeToolDryRun and bypasses
runtime.CallAPI's silent swallowing of non-envelope responses so
gateway and business errors from the new endpoint surface precisely.
The remaining 55 shortcuts will be designed and landed separately,
canonical skill by canonical skill.
* feat(sheets): implement lark_sheet_workbook shortcuts (B1)
Land the 8 modify_workbook_structure shortcuts that round out the
lark_sheet_workbook canonical skill alongside the existing +workbook-info:
+sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy
/ +sheet-hide / +sheet-unhide / +sheet-set-tab-color. All eight call
modify_workbook_structure via the One-OpenAPI invoke_write endpoint,
dispatched by the `operation` enum.
Helpers in helpers.go grow publicSheetFlags() / resolveSheetSelector() /
sheetSelectorForToolInput() / sheetSelectorPlaceholder() so future
sheet-level shortcuts share the public --sheet-id / --sheet-name XOR
treatment. +sheet-create intentionally drops the sheet selector pair since
create has no existing-sheet anchor (matches the spec fix in
tool-shortcut-map.json).
+sheet-delete is the first high-risk-write shortcut in the canonical
package; the framework requires --yes (exit code 10 otherwise).
+sheet-move's tool requires source_index in addition to target_index. The
CLI accepts an optional --source-index override and falls back to a
single get_workbook_structure read to derive it (and to resolve sheet_id
from --sheet-name). DryRun stays network-free by rendering <resolve>
placeholders for any field that would need that read.
* feat(sheets): implement lark_sheet_sheet_structure shortcuts (B2)
Add 8 shortcuts under the lark_sheet_sheet_structure canonical skill:
+sheet-info (get_sheet_structure) plus +dim-insert / +dim-delete /
+dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup
(modify_sheet_structure, dispatched by operation enum).
Two reusable conversion helpers cover the impedance mismatch between
the CLI surface and the tool input:
- dimRange / dimPosition translate the CLI's 0-based exclusive-end
range into the tool's 1-based A1 notation. row 5..8 becomes
position "6" + count 3 (insert) or range "6:8" (range ops); column
26..29 becomes "AA:AC".
- infoTypeFromInclude maps the fine-grained --include vocabulary
(row_heights / col_widths / merges / hidden_rows / hidden_cols /
groups / frozen) to the coarse info_type enum the tool accepts;
mixed categories collapse to "all".
+dim-delete is high-risk-write (irreversible row/column removal).
+dim-freeze --count 0 auto-dispatches to operation=unfreeze. +dim-group
accepts --depth for forward-compat with a future server-side nested
group endpoint but does not pass it through today.
* feat(sheets): implement read_data / search_replace / write_cells shortcuts (B3)
Land 11 shortcuts across three canonical skills:
- lark_sheet_read_data (3): +cells-get / +csv-get / +dropdown-get
- lark_sheet_search_replace (2): +cells-search / +cells-replace
- lark_sheet_write_cells (6): +cells-set / +cells-set-style / +csv-put
/ +dropdown-set / +dropdown-update / +dropdown-delete
+dropdown-get reads the data_validation field via get_cell_ranges with
the range carrying its own sheet prefix (no --sheet-id needed). The
fine-grained --include vocabulary (value / formula / style / comment /
data_validation) maps to the tool's coarse include_styles bool plus
value_render_option enum. +csv-get's --include-row-prefix=false strips
the [row=N] prefix client-side because the tool only emits the
annotated form.
+cells-search / +cells-replace flatten the tool's options sub-object
into four independent flags (--match-case / --match-entire-cell /
--regex / --include-formulas) per the flat-flag rule, then repack them on the way
in.
+cells-set takes a raw --data JSON body whose `cells` array must match
the --range dimensions. +cells-set-style fans a single --style block
out to every cell in the range via a new fillCellsMatrix helper; the
range parser (rangeDimensions / splitCellRef / letterToColumnIndex)
only accepts rectangular A1:B2 forms — whole-column / whole-row need
sheet totals and are deferred.
+dropdown-set fans the validation block out to one range; +dropdown-
update / +dropdown-delete iterate sheet-prefixed --ranges and call
set_cell_range sequentially (partial failure leaves earlier ranges
already mutated; the Tip calls this out). +dropdown-delete is
high-risk-write and requires --yes.
+cells-set-image stays deferred to the cli-only batch (needs the
shared local-file upload helper alongside +workbook-create / +dim-move
/ +workbook-export).
* refactor(sheets): move +dropdown-update / +dropdown-delete to lark_sheet_batch_update
Follow-up to B3 after the spec re-mapped these two shortcuts to the
batch_update tool (atomic multi-range CRUD) instead of fan-out via
set_cell_range. Drop their Go implementations + helper validateDropdownRanges
+ splitSheetPrefixedRange from lark_sheet_write_cells.go and remove the
registrations from Shortcuts(); the shortcuts will reappear under
lark_sheet_batch_update during B7.
Also pull in the re-rendered reference docs:
- skills/lark-sheets/references/lark-sheets-write-cells.md
- skills/lark-sheets/references/lark-sheets-batch-update.md
* feat(sheets): implement lark_sheet_range_operations shortcuts (B4)
Land 8 shortcuts across four canonical tools:
- clear_cell_range → +cells-clear (high-risk-write)
- merge_cells → +cells-merge / +cells-unmerge
- resize_range → +dim-resize
- transform_range → +range-move / +range-copy / +range-fill / +range-sort
Three CLI↔tool vocabulary bridges live in this file:
- +cells-clear: --scope content normalizes to the tool's clear_type
"contents" (singular/plural spec mismatch is absorbed in the CLI).
- +dim-resize: --size <px> wraps as resize_{height,width}:{value:N};
--reset wraps as {reset:true}. The two flags are mutually exclusive
and at least one is required.
- +range-fill: CLI's five-valued --series-type collapses to the tool's
binary fill_type — `copy` → "copyCells", anything else → "fillSeries"
(the actual series progression is inferred server-side from the
seed cells in --source-range).
- +range-copy: --paste-type {values, formulas, formats} maps to the
tool's {value_only, formula_only, format_only}; "all" omits the
field entirely so the server applies its default.
+cells-clear is the second high-risk-write shortcut in the package;
the framework enforces --yes with exit code 10 as usual.
* feat(sheets): implement object-list shortcuts (B5)
Land 7 read shortcuts, one per object skill — chart / pivot table /
conditional format / filter / filter view / sparkline / float image. All
share the same shape (public sheet selector + optional <obj>-id filter)
so they're declared via newObjectListShortcut + an objectListSpec.
Notes:
- +cond-format-list exposes --rule-id, which is renamed to
conditional_format_id on the wire (the tool's full field name).
- +sparkline-list exposes --group-id (the higher-level handle); the
tool also accepts sparkline_id, intentionally not surfaced.
- +filter-list takes no id filter — at most one sheet-level filter
per sheet, so the listing is already unique.
- +filter-view-list is `cli_status: cli-only` but get_filter_view_objects
is in mcp-tools.json and dispatches through the same One-OpenAPI
endpoint; no special path required.
* feat(sheets): implement object CRUD shortcuts (B6)
Land 21 shortcuts — three (create / update / delete) per object skill —
backed by the manage_<obj>_object tools dispatched on the operation
enum. Five standard objects (chart / cond-format / sparkline /
float-image / filter-view) share an objectCRUDSpec factory; pivot and
filter are special-cased.
Shared wire contract:
excel_id + sheet_id|sheet_name + operation + [<obj>_id] + [properties]
CLI --data is passed through as the tool's `properties` field as-is, so
callers shape it per each object's spec doc.
Special cases:
- pivot adds optional --target-sheet-id / --target-position on create
(siblings of properties, not inside it).
- cond-format exposes --rule-id (short CLI name) wired to the tool's
conditional_format_id on the wire.
- sparkline uses --group-id (higher-level object handle) instead of
sparkline_id.
- filter has no separate id flag — at most one filter per sheet, so
filter_id is implicit. +filter-create promotes --range to a first-
class flag (instead of burying it inside --data).
- filter-view CRUD are `cli_status: cli-only` but
manage_filter_view_object is in mcp-tools.json, so they go through
callTool / One-OpenAPI alongside everything else.
All delete shortcuts are high-risk-write and require --yes.
* feat(sheets): implement lark_sheet_batch_update shortcuts (B7)
Land 4 shortcuts that all funnel through the batch_update tool's atomic
operations array:
- +batch-update raw passthrough; --data carries the full
{ operations: [{tool, params}, ...] } payload
plus optional continue_on_error. high-risk-write
since the caller may stuff anything inside.
- +cells-batch-set-style --data is [{ranges, style}, ...]; CLI flattens
each (entry × range) pair into a set_cell_range
op with a fan-out cells matrix carrying
cell_styles + border_styles.
- +dropdown-update --ranges + --options (+ --colors / --multiple /
--highlight) — installs/replaces one dropdown
across many ranges, each becoming a separate
set_cell_range op with data_validation in cells.
- +dropdown-delete --ranges — clears data_validation across many
ranges (high-risk-write).
Default is strict transaction: if any sub-tool fails the whole batch rolls
back. +batch-update exposes --continue-on-error to flip the policy; the
three fan-out shortcuts leave it strict (they're meant to be all-or-nothing).
Reinstates validateDropdownRanges + splitSheetPrefixedRange that were
removed during B3 → B7 relocation.
* feat(sheets): implement cli-only shortcuts (B8) — 70/70 complete
Land the four cli-only shortcuts that can't route through the One-OpenAPI
dispatcher (their backing capabilities aren't in mcp-tools.json):
- +workbook-create POST /open-apis/sheets/v3/spreadsheets
+ optional set_cell_range follow-up that zips
--headers and --data into the first sheet starting
at A1.
- +workbook-export POST /open-apis/drive/v1/export_tasks (type=sheet)
→ poll /export_tasks/:ticket up to ~30s
→ optional GET /export_tasks/file/:file_token/download.
CSV mode requires --sheet-id (single sheet export).
- +dim-move POST /open-apis/sheets/v2/spreadsheets/:token
/dimension_range
CLI is 0-indexed inclusive (--start / --end); the v2
endpoint expects half-open [startIndex, endIndex)
so the body uses endIndex = --end + 1. --sheet-name
is resolved client-side to sheet_id via
lookupSheetIndex when needed.
- +cells-set-image common.UploadDriveMediaAll
(parent_type=sheet_image, parent_node=token)
then callTool set_cell_range with cells carrying
rich_text: [{type:"embed-image", attachment_token, attachment_name}].
--range must be exactly one cell.
All four use runtime.CallAPI / DoAPI directly; only +cells-set-image
combines a legacy upload with the new One-OpenAPI for the second step
(set_cell_range is in mcp-tools.json so callTool is the right path).
This closes the migration: 70 shortcuts × 17 canonical skills × matching
the sheet-skill-spec v0.5.0 tool-shortcut-map.
* test(sheets): cover all 70 shortcuts with dry-run + execute-path tests
Twelve _test.go files alongside the implementation, mirroring the legacy
package's coverage style:
- testhelpers_test.go shared rig: TestFactory + Mount + dry-run
capture + JSON-input decode + envelope helpers.
- lark_sheet_*_test.go one test file per implementation file (9
files), table-driven dry-run cases per shortcut
plus targeted validation guards.
- execute_paths_test.go end-to-end execute paths via httpmock stubs.
Covers callTool unwrap, JSON-string output
decoding, two-step lookup (+sheet-move),
batch_update fan-out, dropdown atomic writes,
and the legacy OAPI shortcuts (+workbook-create,
+dim-move) including CLI inclusive → API
half-open index conversion.
Test coverage on the sheets package is 60.5 % of statements with -race
clean, meeting the dev manual's ≥ 60 % patch-coverage gate.
* refactor(sheets): inline cli-only shortcuts into their canonical skill files
Two naming cleanups:
- lark_sheet_cli_only.go is gone. The four shortcuts it grouped
(+workbook-create / +workbook-export / +dim-move / +cells-set-image)
were bundled by their implementation pattern (legacy OAPI direct
calls) rather than by canonical skill. The whole sheets package IS
the CLI implementation, so "cli only" wasn't a meaningful grouping
at the Go layer. Each shortcut now lives next to its skill peers:
+workbook-create / +workbook-export → lark_sheet_workbook.go
+dim-move → lark_sheet_sheet_structure.go
+cells-set-image → lark_sheet_write_cells.go
Per-skill shortcut counts now match tool-shortcut-map.json exactly
(workbook: 11, sheet_structure: 9, write_cells: 5). Helpers
(buildInitialFillInput, pollExportTask, downloadExportFile,
dimMoveBody) move with their shortcuts; nothing else in the package
referenced them.
- testhelpers_test.go → helpers_test.go. The _test.go suffix already
conveys "test"; the leading "test" was redundant. Matches the
helpers.go naming convention.
Behavior unchanged. go test -race -cover stays at 60.5 %.
* refactor(sheets): sync shortcut flags with sheet-skill-spec v0.5.0
Upstream hoisted a batch of high-frequency scalar fields out of --data
into independent flags and renamed several composite-JSON flags to
match their semantic content. CLI catches up.
Renames (drop-in, same payload semantics):
- +cells-replace --replace → --replacement
- +cells-set --data → --cells
- +workbook-create --data → --values
- +batch-update --data → --operations (now a bare array;
still accepts the envelope form for
back-compat with continue_on_error)
Flat-flag hoists out of --style / --data:
- +cells-set-style / +cells-batch-set-style
--style JSON drops; replaced by 11 flat style flags
(--background-color / --font-color / --font-size / --font-style /
--font-weight / --font-line / --horizontal-alignment /
--vertical-alignment / --word-wrap / --number-format) plus
--border-styles for the one field that's still nested. Both
shortcuts share styleFlatFlags() + buildCellStyleFromFlags().
- +cells-batch-set-style also drops the [{ranges, style}] array shape
in favor of one --ranges + the same flat style flags applied to
all of them.
Object CRUD --data → --properties everywhere (chart / pivot / cond-format
/ filter / filter-view / sparkline / float-image). Per-skill scalar
hoists merged into properties via an enhanceCreate/UpdateInput callback:
- +pivot-create adds --source (required), --range
(and continues to expose --target-sheet-id /
--target-position at top level)
- +cond-format-{create,update}
adds --rule-type (enum) + --ranges (JSON array);
merged into properties.rule.type and
properties.ranges respectively
- +filter-view-{create,update}
adds --view-name and --range; both override
their properties.* counterparts
- +filter-update adds first-class --range (was buried in --data)
Float-image is fully hoisted — no --properties flag at all. Ten flat
flags (--image-name / --image-token | --image-uri / --position-row /
--position-col / --size-width / --size-height / --offset-row /
--offset-col / --z-index) compose the properties block. Implemented as
its own factory (newFloatImageWriteShortcut) since it diverges from the
shared CRUD spec.
Tests track every flag renamed and add explicit cases for the new flag
combos. go test -race -cover stays at 60.3 %.
* refactor(sheets): align batch_update + cells-set with synced reference docs
Sync to upstream reference doc updates for 9 skills:
- batch_update sub-ops: rewrite wire fields tool/params -> tool_name/input
in CellsBatchSetStyle and DropdownUpdate/Delete fan-out (the actual
server contract per Schemas section); update --operations flag desc
and tests.
- +cells-set --cells: accept bare 2D matrix [[{cell},...],...] instead
of envelope {"cells":[[...]]}; spec example shows bare-array form.
- sparkline createDataDesc enum: win_loss -> winLoss (camelCase).
All other doc changes (float-image flat flags, cond-format
--rule-type/--ranges, pivot create-only --source/--range, filter /
filter-view extra flags, chart --properties) were already aligned in
commit ce33315.
* fix(sheets): repair cells-set-image rich_text embed payload
The server rejected set_cell_range calls from +cells-set-image with three
distinct errors: missing "text" property, missing image_width/image_height,
and unknown attachment_token field. Realign the rich_text element to the
embed-image schema (text/image_token/image_width/image_height) and decode
PNG/JPEG/GIF dimensions from the local file before the write.
* refactor(sheets)!: split +dim-resize into +rows-resize and +cols-resize
Sync to upstream spec change that splits the legacy +dim-resize shortcut
into +rows-resize and +cols-resize. Reasoning is that row vs column
resize has divergent semantics (only rows support auto-fit) and the
shared --dimension flag was hiding that.
Behavior changes (BREAKING):
- +dim-resize is removed; use +rows-resize or +cols-resize.
- --dimension and --reset flags are gone.
- --type enum replaces --size/--reset:
pixel (requires --size)
standard (reset to sheet default; no --size)
auto (auto-fit row height; +rows-resize only)
- --end is now inclusive (was exclusive). Old "--start 0 --end 5"
(5 rows) becomes "--start 0 --end 4".
- Wire payload for resize_height / resize_width changes from
{value: N} | {reset: true} to {type: "pixel", value: N} |
{type: "standard"} | {type: "auto"}.
Tests cover both shortcuts across pixel / standard / auto and the
new guard surface (--type pixel needs --size; standard/auto reject
--size; +cols-resize rejects --type auto; --end < --start).
Also pulls in synced reference docs for 5 skills (batch-update,
core-operations, range-operations, sheet-structure, visual-standards)
that update prose mentions of +dim-resize.
* feat(sheets): add --print-schema runtime introspection for composite JSON flags
Composite JSON flags (--cells / --properties / --operations /
--border-styles / --sort-keys / --options) carry non-trivial structured
payloads. Reference docs cover top-level fields but agents writing
those flags often need the full JSON Schema to build a valid payload.
This adds a system-level introspection contract so any shortcut whose
flags are tracked upstream can serve its schemas locally:
lark-cli sheets <shortcut> --print-schema --flag-name <name>
lark-cli sheets <shortcut> --print-schema # list flags
The schema data is embedded at build time from a synced artifact
(shortcuts/sheets/data/flag-schemas.json). Upstream is the source of
truth — never hand-edit the JSON; update the source Base table and
rerun the sheet-skill-spec sync.
Framework changes (shortcuts/common):
- types.go: Shortcut gains an opt-in PrintFlagSchema hook
(flagName -> bytes/error). When non-nil the framework auto-injects
--print-schema / --flag-name and short-circuits Validate/Execute.
- runner.go: register the two system flags when PrintFlagSchema is
set; intercept in runShortcut before identity/scope/config so
pure-local lookups don't trigger auth or network. Install a
PreRunE that relaxes cobra's required-flag gate when
--print-schema is set, since asking for a schema shouldn't need
unrelated required flags.
Sheets surface (shortcuts/sheets):
- flag_schema.go (new): go:embed data/flag-schemas.json; expose
printFlagSchemaFor(command) closure. When flagName is empty it
emits a JSON listing of introspectable flags for discovery;
otherwise it returns the schema subtree as pretty JSON.
- flag_schema_test.go (new): cover embed parsing, listing /
by-name lookup, unknown-flag error path, registration via
Shortcuts(), and the full system-flag short-circuit through
cobra (required flags relaxed, schema printed on stdout).
- shortcuts.go: Shortcuts() now wraps shortcutList() and attaches
PrintFlagSchema to every command present in flag-schemas.json,
so shortcuts opt in by being listed upstream — no per-shortcut
boilerplate.
- data/flag-schemas.json (new, synced from sheet-skill-spec):
19 entries, schema_version "2". Generated upstream from the Lark
Base source-of-truth (see sheet-skill-spec
scripts/fetch_cli_flag_schema_map.mjs); ships only per-flag
subtrees (not the full mcp-tools.json) to keep tool internals
out of the open-source repo.
Skill docs (skills/lark-sheets):
- SKILL.md: system-flag table gains --print-schema / --flag-name and
an "Agent 使用提示" note steering agents to prefer --print-schema
over guessing JSON shape from the cheatsheet.
- references/*.md: regenerated by upstream sync (Schemas-section
boilerplate updated, plus accumulated upstream prose refinements).
* docs(sheets): remove sandbox references and normalize tool names to CLI shortcuts
Replace export_sheet_to_sandbox / import_sandbox_to_sheet / doubao_code_interpreter
with local-script + batch csv-get/csv-put workflows; unify legacy MCP tool names
(set_cell_range, get_range_as_csv, etc.) to CLI shortcut format (+cells-set, +csv-get).
* feat(sheets): add flag-descriptions.en.json and wire applyFlagDescs into Shortcuts()
Embed data/flag-descriptions.en.json (synced from upstream spec) and
apply it at shortcut assembly time so every Flag.Desc is sourced from
the canonical JSON rather than hardcoded Go strings. Existing hardcoded
Desc values serve as fallback for flags not yet in the JSON.
Also sync reference doc updates from upstream.
* feat(shortcuts): support int64 and float64 flag types
Flag.Type previously could not express non-integer numbers. Add int64
and float64 cases to flag registration plus Int64/Float64 runtime
accessors.
* refactor(sheets): build shortcut flags generically from flag-defs.json
Replace flag-descriptions.en.json with the richer flag-defs.json (full
flag definitions: type / default / enum / input / hidden / required /
kind) synced from sheet-skill-spec. Add flagsFor(command) to materialize
each shortcut's []common.Flag straight from the JSON, skipping
system-kind flags the framework injects.
Migrate every sheets shortcut (including the CRUD/list/dim/merge/
visibility factories) to Flags: flagsFor("+command"), dropping all
hand-written flag literals plus the now-dead publicTokenFlags /
publicSheetFlags / styleFlatFlags helpers and enum vars. A coverage test
locks the Go-flags-match-JSON contract.
Align Go with the new spec where they diverged: +cells-get --ranges →
--range, font-size int → float64, +filter-view-create --range now
required, +sheet-create row/col-count defaults 200/20.
* docs(sheets): sync +batch-update CLI override schema (shortcut/input form)
Pulled from sheet-skill-spec:
- skills/lark-sheets/references/lark-sheets-batch-update.md: --operations
now documents the {shortcut, input} form; tool_name references gone
- shortcuts/sheets/data/flag-schemas.json: --operations resolves to the
CLI-side array<{shortcut(enum), input}> schema, sourced from spec's
canonical-spec/tool-schemas/cli-schemas.json (cli: prefix). +dropdown
--options also drilled one level deeper
NOTE: the binary still raw-passes --operations to MCP batch_update which
expects {tool_name, input}. A follow-up will add a shortcut→tool_name
translation layer (with per-shortcut operation field) before the docs
become actionable.
* feat(sheets): translate +batch-update sub-ops {shortcut,input} → MCP shape
Users now hand +batch-update --operations a CLI-shape array
([{shortcut, input}, ...]) and the binary translates each sub-op to the
underlying MCP batch_update shape ({tool_name, input(+operation)}) via
a new dispatch table in shortcuts/sheets/batch_op_dispatch.go.
Dispatch table covers 50 batchable write shortcuts. Excluded by design:
- all read ops
- fan-out wrappers (+batch-update self, +cells-batch-set-style,
+dropdown-update, +dropdown-delete) — nesting these = nested batch
- +dim-move — single shortcut uses legacy v2 /dimension_range endpoint,
not MCP, can't be batched
- +cells-set-image — multi-step image upload, not atomic-batch friendly
- +workbook-create — new workbook, not batch-on-existing semantics
Translator also rejects sub-ops that hand-fill input.operation (implied
by shortcut name) or input.excel_id / spreadsheet_token / url (set
once at +batch-update top level).
+dim-freeze always injects operation=freeze; the count==0 unfreeze
path of the single shortcut is intentionally not supported in batch —
callers should use the single shortcut for unfreeze.
Tests cover: end-to-end translation, --continue-on-error propagation,
13 rejection cases (banned shortcuts, malformed shapes, reserved keys).
Sync'd from sheet-skill-spec: skills/lark-sheets/references/
lark-sheets-batch-update.md + shortcuts/sheets/data/flag-schemas.json
pick up the corrected enum (+cells-set-style / +dropdown-set added,
+dim-move removed).
* fix(sheets): make +batch-update sub-ops reuse standalone flag→body translators
Sub-ops previously near-passed-through their input, so any shortcut whose
standalone translator renames fields broke inside a batch: +range-copy lost
range/destination_range (transform_range errored "range missing") and
+rows-resize lost range/resize_height ("No resize operation specified").
Introduce a flagView interface (satisfied by *common.RuntimeContext) and a
map-backed mapFlagView, then route every batchable sub-op through the SAME
*Input builder the standalone shortcut uses. mapFlagView seeds flag-defs.json
defaults for value reads while keeping Changed() user-driven, so a sub-op body
is byte-identical to the standalone body — locked by a batch-vs-standalone
contract test over all ~40 batchable shortcuts.
Also fix single-row/column resize: start==end now formats as "23:23" / "C:C"
(resize_range rejects a bare "23"); dimRangeFull keeps both sides while
dimRange's collapse stays for modify_sheet_structure consumers.
* fix(sheets): align +cells-get/+csv-get range flags with synced spec
sheet-skill-spec now declares +cells-get --range as a single string
(was string_array) and +csv-get --range as required. Match the
flag→body translators:
- +cells-get wraps the single --range into the tool's `ranges` array
and validates with Str() instead of StrArray(), which silently
returned nil against the now-String flag and broke the command.
- +csv-get gains a trim-based required-range guard.
Update read-data dry-run tests to single-range form and add a guard
test for the empty --range path.
* fix(sheets): push +batch-update sub-op validation down into xxxInput builders
Sub-ops that omit --sheet-id (or any other required flag) used to slip
past CLI validation — Validate ran only against the standalone shortcut
path, and batchOpDispatch's translators built bodies from whatever
flagView returned, so a structurally broken sub-op surfaced as an opaque
server "sheet undefined not found" after a network round-trip.
Push each batchable shortcut's check trio down into its xxxInput builder:
1. resolveSpreadsheetToken — stays in Validate (batch already does it
once at the top level; sub-ops don't repeat).
2. requireSheetSelector(sheetID, sheetName) — new helper; flagView-
agnostic XOR + control-char check, called at the top of every
xxxInput.
3. shortcut-specific required / range / enum checks (--dimension,
--range, --start <= --end, --type pixel needs --size,
--float-image-id, image-token XOR image-uri, ...) — moved out of
Validate into the builder body.
All ~30 batchable xxxInput builders now return (map, error). Standalone
Validate shrinks to validateViaInput(xxxInput); DryRun / Execute
propagate the error. batch_op_dispatch entries drop the noErrTranslate
wrapper and pass the builder directly — its error bubbles up wrapped
with "operations[N] (+shortcut):" context.
Tests:
- TestBatchOp_ErrorEquivalence (7 cases): XOR / logical-constraint
errors fire identically from standalone and batch sub-op paths.
- TestBatchOp_RejectsBadSubOpInput (8 cases): cobra-required flags that
standalone catches via MarkFlagRequired now also get rejected CLI-side
on the batch path (where cobra is not in the loop).
- TestBatchOp_BodyMatchesStandalone (~40 cases) and
TestBatchOp_DispatchCoversReportedBugs continue to pass — bodies stay
byte-identical.
- BOE smoke (spreadsheet ICFwstkUGheyfptGWS2bB7RgcDf, sheet 51991c):
+batch-update with a sub-op missing --sheet-id now returns
"operations[0] (+dim-insert): specify at least one of --sheet-id or
--sheet-name" before any network call.
sheetMoveBatchInput (xiongyuanwen's batch-only explicit-source-index
requirement) is preserved — it's an orthogonal batch-specific constraint
not affected by this push-down.
* fix(sheets): align +cond-format / +filter with server schema (#4 + #5)
Two latent bugs in the object_crud translator surfaced during BOE smoke
testing of +batch-update. Both are schema-alignment fixes against
manage_conditional_format_object / manage_filter_object as declared in
sheet-skill-spec/canonical-spec/tool-schemas/mcp-tools.json.
#4 +cond-format: rule_type path + enum vocabulary
---------------------------------------------------
condFormatEnhance used to write the user's --rule-type value into
`properties.rule.type` (nested under a `rule` object). The server
schema actually puts it at flat `properties.rule_type` and silently
drops the nested form — so every conditional-format create/update
secretly built the wrong document.
Worse, the CLI enum exposed via flag-defs.json was its own invented
vocabulary (cellValue / formula / duplicate / unique / topBottom /
aboveBelowAverage / dataBar / colorScale / iconSet / textContains /
dateOccurring / blankCell / errorCell) — none of those values were
the strings the server accepts.
Fix:
- condFormatEnhance now writes `properties.rule_type = <value>`
directly (no nested `rule` object).
- Synced flag-defs.json + lark-sheets-conditional-format.md enum
vocabulary from base to match the server: duplicateValues,
uniqueValues, cellIs, containsText, timePeriod, containsBlanks,
notContainsBlanks, dataBar, colorScale, rank, aboveAverage,
expression, iconSet.
- ⚠️ Breaking: scripts passing the old CLI-invented enum values
(e.g. --rule-type cellValue) now get a cobra "invalid value …
allowed: …" error listing the new vocabulary. No alias layer.
- TestObjectCRUDShortcuts_DryRun's +cond-format-update case updated
to assert the flat properties.rule_type shape + new enum.
#5 +filter-{update,delete}: auto-inject filter_id = sheet_id
-------------------------------------------------------------
manage_filter_object's contract is "filter_id === sheet_id" for the
sheet-scoped filter (per per-tool description in mcp-tools.json),
and update / delete operations MUST carry filter_id. Standalone
filterUpdateInput / filterDeleteInput never set it, so the server
rejected with "filter_id is required for update/delete operation"
on every call — both standalone AND inside +batch-update.
Fix:
- filterUpdateInput / filterDeleteInput now set
input["filter_id"] = sheetID.
- Because filter_id must equal sheet_id (not sheet_name), update /
delete reject when only --sheet-name is given — there's no
network lookup available inside the builder. The friendly error
points at +workbook-info for resolving sheet-name → sheet-id.
- create still omits filter_id (server requires that — id is
server-allocated on creation).
- New tests:
* TestObjectCRUDShortcuts_DryRun gains a +filter-update happy-path
case asserting filter_id is auto-injected + --range hoisting.
* +filter-delete case updated to assert filter_id presence.
* TestBatchOp_RejectsBadSubOpInput gains two cases asserting both
+filter-update and +filter-delete reject --sheet-name-only with
the friendly error.
Docs (#2 + #3 + #8) synced from sheet-skill-spec
-------------------------------------------------
Companion doc fixes that landed via npm run generate:cli + sync:cli
in sheet-skill-spec; included here because the regenerated flag-defs
and references markdown are byte-tracked in this repo:
- #2: lark-sheets-sheet-structure.md — +dim-{hide,unhide,group,
ungroup} --start/--end desc changed from "(0-based, inclusive)" to
"(0-based)" / "(exclusive)" to match the half-open range semantics
the code has always implemented (requireDimRange: end > start;
dimRange uses end - 1 for column end letters).
- #3: lark-sheets-workbook.md — +sheet-move section gains a note
about the batch-internal requirement to pass --sheet-id AND
--source-index explicitly (sheetMoveBatchInput's constraint).
- #8: lark-sheets-pivot-table.md — +pivot-create --properties
example drops the stale data_range field (the actual server
schema uses --source as a hoisted flag; properties only carries
rows / columns / values / filters / show_*_grand_total).
* feat(sheets): add +cells-batch-clear fan-out over batch_update
Clear content/formats across many sheet-prefixed ranges in a single atomic
batch_update (one clear_cell_range op per range), mirroring the existing
+cells-batch-set-style / +dropdown-{update,delete} fan-out wrappers. The
--scope to clear_type normalization is shared with standalone +cells-clear
(normalizeClearType) so the two stay in lockstep.
high-risk-write (requires --yes); rejected as a batch sub-op like the other
fan-out wrappers. flag-defs/flag-schemas and skill docs updated to match.
* docs(sheets): sync stdin guidance and sparkline reference
- skills/lark-shared/SKILL.md: drop the generic "prefer stdin" section
- skills/lark-sheets/SKILL.md: add expanded stdin guidance (use stdin over @file abs paths; don't cd or write into the project dir)
- skills/lark-sheets/references/lark-sheets-sparkline.md: document the group_id / sparkline_id two-tier model with worked examples
* fix(sheets): require sparkline_id on +sparkline-update items (#6)
manage_sparkline_object uses two layers of IDs: --group-id picks the
sparkline group, and properties.sparklines[i].sparkline_id picks each
item inside the group. The server contract requires sparkline_id on
every update item (server maps each entry back to an existing
sparkline by this id). Agents that called +sparkline-update without
the per-item ids hit an opaque server-side rejection that didn't
mention sparkline_id at all, then got stuck in a try-fail-list-retry
loop.
Pre-check CLI-side in objectUpdateInput via a new validateUpdateInput
hook on objectCRUDSpec. sparklineSpec wires validateSparklineUpdateItems,
which walks properties.sparklines[] and rejects with a message that
points at +sparkline-list:
+sparkline-update properties.sparklines[N] 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)
Scope is update-only. config-only updates (properties.config without
sparklines) stay legal — the validator skips when sparklines is
absent. Delete is not pre-checked: objectDeleteInput doesn't pass
properties through, so the partial-delete branch can't be reached
today (separate follow-up).
Tests:
- TestObjectCRUDShortcuts_DryRun: positive case for update with
sparkline_id present.
- TestSparklineUpdate_MissingSparklineID: standalone path — error
contains both "missing sparkline_id" and "+sparkline-list".
- TestBatchOp_RejectsBadSubOpInput: batch sub-op missing sparkline_id
rejected with the same friendly error.
Docs synced from sheet-skill-spec (canonical change committed there):
skills/lark-sheets/references/lark-sheets-sparkline.md documents the
two-layer id model, the three "+sparkline-list first" cases, and both
delete modes.
* docs(sheets): sync lark-sheets skill from spec (audit 20260521)
Pull latest spec from sheet-skill-spec (PR ee/sheet-skill-spec!6 + earlier
develop commits) into skills/lark-sheets/ and shortcuts/sheets/data/.
Audit findings now reflected in CLI docs:
- A2 +cond-format-create example: --rule-type duplicate → duplicateValues
- A3 +cond-format-create Validate: cellValue/formula → cellIs/expression
- A5 +csv-put examples: --range → --start-cell; drop redundant --allow-overwrite
- A7 +sparkline-create: Validate / Examples aligned with real schema
(config/sparklines), executable JSON example added
- B13 cross-doc dead links: lark_sheet_*/cli-shortcuts.md → lark-sheets-*.md
- C2 +csv-put: `=` literal warning next to Examples
- CC5 +rows-resize/+cols-resize --type auto: single point of truth in
range-operations reference
flag-defs.json description / required sync (from base):
- A4 +float-image-update: image-name/position-*/size-* required → optional
(patch mode)
- A8 +dim-move --start/--end description cleanup
- B3 +pivot-create --properties: data_range → source (real field name)
Also picks up the +cells-batch-clear shortcut doc (introduced in spec
develop). Go-side implementation for that shortcut is intentionally not
in this PR — docs-only preview; runtime dispatch will land in a follow-up.
`go test ./shortcuts/sheets/...` passes.
* feat(sheets): add +cells-set --copy-to-range and sync skill spec
Sync lark-sheets skill references and flag schemas from upstream
sheet-skill-spec, and wire the newly-specced --copy-to-range flag into
+cells-set: it passes copy_to_range to the set_cell_range tool so a
template block written via --cells fans out across a larger range with
auto-shifted formula refs.
* docs(sheets): sync lark-sheets skill spec (chart/pivot wire mappings, --end semantics)
Sync skill references and flag-defs descriptions from upstream
sheet-skill-spec: clarify +chart-create properties structure
(snapshot.data), +pivot-create --target-position / --range wire-field
mappings, add a cross-command --end endpoint-semantics table
(insert/delete/hide/group exclusive vs move/resize inclusive), note
--group-state default, and rename reference identifiers to lark-sheets-*.
Description-only refinement; the existing CLI implementation already
matches the clarified wire mappings and --end semantics.
* fix(sheets): make --max-chars the single read cap for +cells-get / +csv-get
Drop --cell-limit (+cells-get) and --max-rows (+csv-get) from the CLI surface
and pin the underlying tool's cell_limit / max_rows to a very large sentinel so
the tool's own defaults never truncate before --max-chars. --max-chars stays the
only knob (default 200000, unchanged).
- lark_sheet_read_data.go: add unboundedReadLimit (1e9); cellsGetInput pins
cell_limit, csvGetInput pins max_rows; --max-chars still passed through
- data/flag-defs.json: synced from spec (drops the two flags)
- tests: spot-check moved to --max-chars; dry-run wantInput asserts cell_limit /
max_rows are pinned high
Mirrors sheet-skill-spec (Base flag records removed).
go build ./... + go test ./shortcuts/sheets/ green.
* docs(sheets): sync lark-sheets read docs — --max-chars as single read cap
Sync skills/lark-sheets references from spec: drop --cell-limit / --max-rows
guidance; 大表分批读 switches to --range row windows + --max-chars auto cap + has_more.
Mirrors sheet-skill-spec 58e7456 and handler change 2befc49.
* docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec (--depth wording for +dim-group / +dim-ungroup,
plus assorted reference clarifications). Description-only; no CLI
behavior or flag surface change.
* docs(sheets): sync chart properties schema (position/size required)
Regenerate flag-schemas.json from upstream sheet-skill-spec: the chart
properties schema now marks position and size as required, and the chart
reference doc reflects the same. flag-schemas.json is print-schema-only
(no client-side validation), so this is a generated-artifact + doc sync
with no CLI behavior change.
* docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs and flag-defs descriptions from upstream
sheet-skill-spec: clarify +workbook-export sheet flag scope, +filter-*
--properties optionality (omitted => empty filter on --range; rules must
be non-empty when provided), float-image reference_id wording, and
assorted reference cleanups. Description-only; existing CLI behavior
(filter passthrough, properties optional) already matches.
* docs(sheets): sync lark-sheets skill spec from upstream
Trim and refine reference docs from upstream sheet-skill-spec
(condense core-operations workflow, tidy write-cells / range-operations /
float-image / SKILL guidance). Description-only; no flag or CLI behavior
change.
* docs(sheets): sync lark-sheets skill spec from upstream
Refine reference docs from upstream sheet-skill-spec (core-operations,
formula-translation, visual-standards, SKILL guidance). Description-only;
no flag or CLI behavior change.
* fix(sheets): correct +workbook-create initial fill and +dim-move endpoint
+workbook-create: the v3 create response does not echo the default sheet's id, so the initial-fill set_cell_range was sent with an empty sheet_id and rejected ("sheet_id or sheet_name is required"). Resolve the workbook's first sheet via get_workbook_structure before filling.
+dim-move: the move request was POSTed to the v2 dimension_range endpoint (the add/update/delete surface, which requires a `dimension` object) and rejected with "[9499] Missing required parameter: Dimension". Switch to the native v3 move_dimension endpoint (sheet_id in path; snake_case source.{major_dimension,start_index,end_index} + destination_index). CLI --end and v3 end_index are both 0-based inclusive, so they pass through unchanged.
* fix(sheets): align +workbook-create, +dropdown-*, +dim-move, +range-sort with server schema
Five separate E2E failures in shortcuts/sheets/ that all trace back to a
CLI ↔ server contract mismatch. Each is independently scoped; bundling
them because they share the test-report citation and the same one-line
fix shape in most cases.
buildInitialFillInput sent {"sheet_id": ""} on the secondary
set_cell_range call after creating the workbook. The empty value was a
holdover from "...otherwise server picks first sheet" — but
set_cell_range rejects an empty selector with
"sheet_id or sheet_name is required" rather than falling back to the
default sheet.
Use sheet_name "Sheet1" instead. POST /sheets/v3/spreadsheets always
creates that sheet on workbook creation, and set_cell_range accepts
sheet_name as an equivalent selector — saves an extra
get_workbook_structure round-trip just to learn the auto-generated id.
buildDropdownValidation emitted four fields that don't exist in the
canonical set_cell_range.data_validation schema:
- "values" (options list) → renamed to "items"
- "multiple_values" → renamed to "support_multiple_values"
- "colors" (per-option color) → removed (not in schema; flag also
removed from data/flag-defs.json
for +dropdown-set / -update)
- "highlight_options" → removed (not in schema; flag also
removed)
The canonical schema lives at sheet-skill-spec/canonical-spec/tool-
schemas/mcp-tools.json (set_cell_range tool, data_validation property);
the colors / highlight knobs were CLI inventions the server never
accepted, so removing the flags is correct (renaming would leave the
flags broken). Skill reference docs (write-cells.md, batch-update.md)
synced.
validateDropdownOptionsColors lost its colors check; renamed to
validateDropdownOptions to reflect the narrower contract.
dropdownGetInput sent "Sheet1!C2:C6" verbatim as a ranges[] entry.
get_cell_ranges expects sheet_id / sheet_name as separate fields and
ranges entries without the sheet prefix; the server bounced with
"sheet not found, sheetId:" (empty).
Use the existing splitSheetPrefixedRange helper (declared in
lark_sheet_batch_update.go) to break "Sheet1!C2:C6" into ("Sheet1",
"C2:C6"), then thread the sheet name through sheetSelectorForToolInput
exactly like +cells-get does.
The shortcut was POSTing to /sheets/v2/spreadsheets/{token}/dimension_
range, which is the v2 insert-dimension endpoint and requires a top-
level {"dimension": {...}} body. Move uses a separate endpoint:
POST /sheets/v2/spreadsheets/{token}/move_dimension
body: { "source": {...}, "destination_index": N }
(camelCase "destinationIndex" → snake_case "destination_index" to
match the v2 contract.) Both DryRun and Execute updated, plus the
TestDimMove_DryRun and TestExecute_DimMove assertions.
transform_range.sort_conditions[i] requires both `column` (string) and
`ascending` (bool); rangeSortInput passed the --sort-keys array through
to the server unvalidated, so missing fields surfaced as opaque
"required property X missing" errors with no per-item context.
Walk the parsed array client-side, reject with item-pointing messages.
Test fixtures and a contract-test fixture switched from the historical
{col, order} vocabulary (which the server has never accepted) to the
correct {column, ascending}.
Server-schema citations and test-report case mapping in this branch's
plan file.
* revert(sheets): drop direct flag-defs.json edits — generated from spec
data/flag-defs.json is regenerated from the upstream sheet-skill-spec
canonical-spec; editing it here gets clobbered on the next sync. The
schema realignment for +dropdown-set / -update --colors / --highlight
removal needs to land on the base table first, then flow back through
sheet-skill-spec → larksuite-cli sync, not via a direct CLI-side edit.
Restore the previous flag entries verbatim. The Go-side change in
buildDropdownValidation still drops the wire fields, so:
- users passing --colors / --highlight today see the flag accepted
silently (no effect on the wire) until the upstream removal lands;
- after upstream removal + sync, both the flag declarations and the
Go-side handling will be in sync.
Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
* revert(sheets): drop direct edits to skills/lark-sheets/references/
These md files are sync targets generated from sheet-skill-spec; editing
them here gets clobbered on the next sync, same as data/flag-defs.json.
The --colors / --highlight row removals belong on the upstream base
table → canonical-spec sync, not here.
Restore the previous --colors / --highlight rows in both
lark-sheets-write-cells.md (+dropdown-set) and lark-sheets-batch-update.md
(+dropdown-update). The Go-side change in buildDropdownValidation still
drops the wire fields, so:
- users passing --colors / --highlight today see the flag accepted
silently (no effect on the wire) until upstream removes the flag;
- after upstream removal + sync, both flag declarations, ref docs, and
Go-side handling will be in sync.
Functional fixes (#1 workbook-create, #3 dropdown-get, #4 dim-move,
#5 range-sort) and dropdown wire-shape rename (#2) are unaffected.
* docs(sheets): sync from sheet-skill-spec — remove dropdown --colors / --highlight
Upstream sheet-skill-spec base table deleted the --colors and --highlight
flags on +dropdown-set / +dropdown-update (the corresponding wire fields
data_validation.colors / .highlight_options were never accepted by the
server schema; see prior fix in this branch). Re-running the sync from
canonical-spec brings the CLI flag-defs and skill reference docs back in
line with the Go-side handling that already drops these fields.
Generated by `npm run sync:cli` in sheet-skill-spec @ ac7acef.
* fix(sheets): restore +dropdown --colors / --highlight, map to canonical fields
Reverses the --colors / --highlight removal from 7932ab2 (item #2 of the
batch-1 schema-alignment commit). That commit dropped both flags after the
test report flagged data_validation.colors / highlight_options as "unexpected
property" — at the time the canonical set_cell_range.data_validation schema
listed only help_text / items / operator / range / support_multiple_values /
type / values, so the flags had no server-side target and the removal was
correct.
Since then, set_cell_range.data_validation has gained two fields explicitly
modelling the dropdown highlight UI (mcp-tools.json in sheet-skill-spec
2026-05-22 base sync):
enable_highlight (bool) — show pill backgrounds
highlight_colors (string[]) — hex pill colors, length must match items
So the flags are back, but rewired:
--colors -> data_validation.highlight_colors (was: colors)
--highlight -> data_validation.enable_highlight (was: highlight_options)
--options -> items and --multiple -> support_multiple_values renames from
7932ab2 are kept.
Changes:
- buildDropdownValidation: re-add --colors / --highlight handling against
the new field names; --colors length check stays inline (so dropdownSetInput
Validate path catches it via validateViaInput, no separate guard needed).
- validateDropdownOptions -> validateDropdownOptionsColors: restore the
Validate-time --colors length check on +dropdown-update / +dropdown-delete
(called from lark_sheet_batch_update.go).
- TestDropdownSet_CellsShape: extend to assert highlight_colors /
enable_highlight emitted; assert legacy `colors` / `highlight_options`
absent.
- TestDropdownSet_ColorsLengthMismatch: new — covers the early Validate
error path.
- TestDropdownUpdate_BatchPayload: extend to cover dropdownBatchInput
propagation of --colors / --highlight through batch_update.
- skills/lark-sheets/references/lark-sheets-{write-cells,batch-update}.md,
shortcuts/sheets/data/flag-defs.json, flag-schemas.json: synced from
sheet-skill-spec generate output (MR !7).
* chore(sheets): re-sync from spec + loosen --colors length check
Catches up to sheet-skill-spec's 2026-05-25 base sync (MR !7) after
rebasing onto upstream feat/lark-sheets-refactor (12 new upstream commits
including the lark-sheets skill refactor + tools-schema migration).
Spec changes flowing in:
- highlight_colors description loosened: length may be **shorter than**
--options (server cycles remaining slots through a built-in 10-color
palette); previously the tool errored on any length mismatch.
- shortcuts/sheets/data/flag-schemas.json: mass re-mirror — generator now
emits `type` before `properties` and adds explicit `additionalProperties:
false` on object schemas (cosmetic, no behavior change).
- skills/lark-sheets/references/lark-sheets-{batch-update,chart,write-cells}.md:
--options gains the type='list' tag; data_validation inline field-count
goes 7 → 9 (catches up the highlight schema in the summary); chart
position / size marked optional per upstream.
Go-side adjustment:
- buildDropdownValidation / validateDropdownOptionsColors: change the
--colors length check from strict-equal to "must not exceed --options"
to match the relaxed schema.
- TestDropdownSet_ColorsLengthMismatch -> TestDropdownSet_ColorsLongerThanOptions
(now hits the overflow path with 3 colors vs 2 options).
- New TestDropdownSet_ColorsShorterAccepted: 2 colors vs 4 options is
legal and forwarded as-is.
* docs(sheets): sync dropdown --colors/--highlight clarification from spec
Mirrors sheet-skill-spec MR !7 changes:
- skills/lark-sheets/references/lark-sheets-write-cells.md: new "Dropdown
配色" section explaining how --colors (→ data_validation.highlight_colors)
and --highlight (→ data_validation.enable_highlight) compose — length
rule (shorter ok, longer rejected), --highlight gating, palette
fallback behavior, minimal +dropdown-set example.
- skills/lark-sheets/references/lark-sheets-batch-update.md: one-line
pointer to the write_cells section for +dropdown-update / -delete
(same rules).
- shortcuts/sheets/data/flag-defs.json: --colors / --highlight `desc`
fields gain the long-form server-field / length-rule descriptions
used by `--help`.
No Go-side change — earlier commit 538eb2e already loosened the
buildDropdownValidation length check to "must not exceed"; this PR step
just makes the docs / `--help` text catch up.
* feat(sheets): +dropdown-set/-update --source-range for listFromRange mode
Previously +dropdown-set / +dropdown-update only emitted
data_validation.type=list — agents wanting listFromRange (dropdown options
sourced from existing cells, kept in sync with that range) had to drop down
to +cells-set and hand-build a data_validation map. The flag now exposes it
natively as --source-range, paired with --options under XOR.
CLI changes:
- shortcuts/sheets/lark_sheet_write_cells.go:
* new dropdownTypeAndItems(runtime) — central XOR resolver: rejects 0 or
2 of {--options, --source-range}, returns (sourceSize, partial dv with
type+items|range filled in). Source size = options length for list
mode, rangeDimensions(--source-range) cell count for listFromRange.
* buildDropdownValidation rewritten to call the resolver, then layer
--colors / --multiple / --highlight on top — semantics unchanged
for callers, just two modes instead of one.
* validateDropdownOptions / -Colors renamed to validateDropdownSourceOrOptions
so the XOR + length check fires at +dropdown-update Validate time too.
* --colors length error message generalized: "must not exceed dropdown
source size (N)" (covers both modes).
- shortcuts/sheets/lark_sheet_batch_update.go: rename call site.
- shortcuts/sheets/lark_sheet_write_cells_test.go: 4 new tests —
ListFromRange (happy path: range + items absent + colors + highlight all
emit), ListFromRange_ColorsLongerThanCells (overflow against T1:T3 cell
count), XorBothSet, XorNeitherSet. Updated the existing
ColorsLongerThanOptions assertion to match the new "source size" wording.
Spec-driven changes (synced via npm run sync:cli from sheet-skill-spec
MR !7 2c298b6):
- shortcuts/sheets/data/flag-defs.json: --options Required flips to xor on
+dropdown-set/-update; new --source-range row gains long-form description
pointing at server data_validation.range + the XOR semantics.
- skills/lark-sheets/references/lark-sheets-write-cells.md: "Dropdown 配色"
section reorganized into "Dropdown 选项 + 配色" — XOR comparison table
(list vs listFromRange), shared config flag table (--highlight /
--colors), explicit length rule covering both modes, side-by-side
minimal examples, server-range-normalization gotcha callout.
- skills/lark-sheets/references/lark-sheets-batch-update.md pointer updated
to mention both modes + that +dropdown-delete is unaffected.
PPE smoke (ppe_lark_cli_sheet) on UFJxszjrZhZ1LVtc9FdcICSbn6b C column:
- +cells-set C1 → "性别" (bold + centered): updated_cells_count=1
- +dropdown-set --range C2:C21 --source-range "Sheet1!T1:T3" --colors
'["#cce8ff","#ffd6e7","#e6e6e6"]' --highlight: updated_cells_count=20
- read-back: data_validation.type=listFromRange + range=$T$1:$T$3 (server
normalizes the prefix away on storage; highlight_colors /
enable_highlight not echoed by get_cell_ranges, see byted-sheet read
projection TODO).
- error-path replay (both XOR violations + colors > source-size) all
rejected at Validate stage with the expected messages.
* docs(sheets): sync agent-voice rewrite of Dropdown 选项+配色 from spec
Mirrors sheet-skill-spec MR !7 60df610 — narrative now describes how the
flags interact (XOR, colors length rule, highlight gating, sheet-prefix
read-back gotcha) without exposing the underlying data_validation field
names or server-side normalization details that agents don't act on.
No Go-side change, no shortcut behavior change.
* chore(sheets): restore --colors in parseJSONFlag docstring example list
The earlier commit 49104ec swapped --colors out of parseJSONFlag's "Used
by" example list when it deleted the flag (item #2 there removed --colors
/ --highlight from +dropdown-set/-update). Subsequent commits 8672d8e /
538eb2e / fb90c8b reinstated --colors (and added --source-range) but did
not roll back this docstring tweak — leaving an orphan reference to
--properties where --colors used to be.
This restores the example list to its pre-49104ec form so the docstring
matches what the helper actually services on this branch's HEAD.
Pure docstring change — function behavior unaffected, no test movement.
* fix(sheets): post-rebase test fixups after dropping superseded fix #1
Two test fallouts from rebasing onto upstream 4be06c8 (which independently
re-fixed +workbook-create and +dim-move with a more thorough approach):
- shortcuts/sheets/lark_sheet_workbook_test.go: our PR's earlier
TestWorkbookCreate_DryRun "with headers and data → 2-step plan" subtest
asserted the expedient sheet_name="Sheet1" / no-sheet_id wire body that
matched our dropped fix #1 implementation. Upstream's fix #1 resolves
the workbook's first sheet via get_workbook_structure and fills with
the real sheet_id instead. Reset this file to upstream's version — our
superseded assertions disappear, upstream's tests cover the new wire
shape.
- shortcuts/sheets/execute_paths_test.go: TestExecute_RangeSort fixture
still used the legacy {col, order} sort-key shape because the rebase
resolution picked the upstream version of this file wholesale (it
contained other unrelated changes). Re-apply just the one fixture
update to {column, ascending} so fix #5's CLI-side rejection logic
exercises a valid input — server-side sort_conditions has required
fields `column` (string) and `ascending` (bool); the historical
{col, order} vocabulary was never accepted.
go build ./... + go test ./shortcuts/sheets/... -count=1 both green.
* feat(sheets): +dropdown --highlight tri-state via Changed() for opt-out
The server-side default for data_validation.enable_highlight flipped from
false to true (aligning with the UI behavior). With the previous code path
if runtime.Bool("highlight") { dv["enable_highlight"] = true }
omitting --highlight and passing --highlight=false both produced the same
"enable_highlight key absent" body, leaving CLI users with no way to opt
out of the (now-default) highlighting.
Switch to runtime.Changed() so the translator can distinguish all three
input shapes:
- omitted -> no enable_highlight key (server applies default=true)
- --highlight=true -> enable_highlight: true (explicit no-op vs default)
- --highlight=false -> enable_highlight: false (the only opt-out path)
flagView already exposes Changed() and mapFlagView (the +batch-update
sub-op adapter) implements it via raw-key presence — same pattern other
translators use for "Changed-only" branching (e.g. omit target_index
unless --index was set), so no interface surface change is needed.
Test coverage:
- TestDropdownSet_HighlightTriState pins all four shapes (omit / presence
form / explicit true / explicit false) and asserts the enable_highlight
key's presence/value
- TestBatchOp_BodyMatchesStandalone adds a --highlight=false sub-op case
so the batch sub-op path produces a body byte-identical to the
standalone +dropdown-set --highlight=false body
* chore(sheets): sync +dropdown flag desc + write-cells narrative from spec
Mirror sheet-skill-spec generated/ into shortcuts/sheets/data/ and
skills/lark-sheets/ for the +dropdown-set / +dropdown-update path. No
hand edits in this repo.
The +dropdown flag desc and the Dropdown 配色 narrative now match the
server-side enable_highlight default flip (true) and the tri-state
--highlight semantics introduced in the sibling commit:
* --highlight desc: 不传 = 开(按内置 10 色色板循环上色),
--highlight=false 关闭得到纯白下拉
* --colors desc: 单独传即生效(高亮默认开),--highlight=false 时忽略
* write-cells reference: 三种意图三条线(默认色板 / 指定颜色 /
纯白下拉)+ 新增 --highlight=false 示例
Source upstream: sheet-skill-spec MR !8.
* fix(sheets): validate +cells-set-image --image path in Validate
The unsafe-path check only ran at Execute (via FileIO.Stat), so --dry-run
printed a misleading success preview for an absolute / out-of-cwd --image
path that a real run would then reject. Move the path-safety check into
Validate (validate.SafeLocalFlagPath), so --dry-run and Execute fail
identically and both name the real --image flag. File existence stays
deferred to Execute, so legitimate relative paths still preview cleanly.
Add TestCellsSetImage_DryRunRejectsUnsafePath.
* feat(sheets): support local --image in +float-image-create
+float-image-create now accepts a local file via --image (XOR with
--image-token / --image-uri): the CLI uploads it as a sheet_image and
embeds the returned file_token, removing the previous "upload elsewhere
to get a token first" workaround. Path safety is checked in Validate,
--dry-run previews the extra upload step, and +batch-update rejects
--image (no upload phase). +float-image-update is unchanged (it does not
register --image).
Also syncs the lark-sheets skill docs/flag-defs from sheet-skill-spec:
the new --image flag, partial-merge / border-per-side / bare sheet-prefix
clarifications, and refreshed dropdown --colors/--highlight descriptions
(already pending in the source Base table).
* fix(sheets): +dropdown-get accepts --sheet-id/--sheet-name + bare --range
Align +dropdown-get with its get_cell_ranges siblings (+cells-get / +csv-get):
sheet selection is now via --sheet-id / --sheet-name (XOR) and --range is a
bare A1 reference. The previous shape required the sheet prefix inside --range
(e.g. "Sheet1!A2:A100") and was the odd one out among the read-data wrappers;
callers pasting the sheet-id form straight from the URL hit a misleading
"sheet not found, sheetId: , sheetName: <id>" error because the prefix was
unconditionally treated as sheet_name.
Flag schema + skill reference regenerated from the upstream Lark Base
Shortcut-flags table.
* fix(sheets): drop Sheet1! prefix from +cells-get / +csv-get / +csv-put flag examples
Server tools-schema.json for get_cell_ranges, get_range_as_csv and set_range_from_csv
does not accept a sheet prefix on --range / --start-cell; the sheet is selected via
--sheet-id / --sheet-name. +csv-put --start-cell also now states it must be a single
cell (no range notation).
Synced from spec repo.
* feat: 把环境变量提交上去
* fix(sheets): clarify batch --ranges prefix must be sheet display name
E2E test cases repeatedly trip on this:
$ lark-cli sheets +cells-batch-set-style \
--ranges '["7f8fba!A2:B3","7f8fba!C2:D3"]' --font-color '#3366FF' ...
→ tool "batch_update" failed: [900015206]
sheet "7f8fba" not found. Available sheets: [{id: "7f8fba", name: "Sheet1"}]
Callers paste the hex sheet-id (e.g. "7f8fba") from a spreadsheet URL /
+sheet-create response straight into the --ranges sheet prefix. The four
batch shortcuts (+cells-batch-set-style / +cells-batch-clear /
+dropdown-update / +dropdown-delete) fan each range out into a
batch_update sub-op (set_cell_range / clear_cell_range) and pass the
prefix through as sheet_name; the server only matches sheet_name
literally, so the lookup fails.
The set_cell_range tool schema is explicit: sheet_id is the
reference_id and "must be correct or it errors"; sheet_name is the
display name. CLI can't disambiguate purely from the literal because
users can rename sheets to anything (including six-char hex strings).
Cleanest fix is at the source: each batch shortcut's --ranges flag
description now states explicitly that the prefix must be the sheet
display name and that the sheet reference_id is rejected, so agents
reading the reference don't try the id form in the first place.
No Go changes; these files are regenerated from the upstream Lark Base
Shortcut-flags table via the sheet-skill-spec sync chain.
* docs(sheets): sync lark-sheets skill docs from upstream spec
- SKILL.md: clarify --url only resolves /sheets/ and /spreadsheets/ links; /wiki/ links must be resolved via wiki +node-get first (confirm obj_type=sheet, use obj_token)
- formula-translation: document IMPORTRANGE cross-workbook limits (max 5-level nesting, 100 refs per sheet)
- write-cells: document rich_text cells for hyperlinks, @mentions and @docs
* feat: 同步 tools-schema.json 改动
* fix(sheets): warn when +dropdown source-range exceeds 2000 cells with highlight on
byted-sheet's ListFromRangeValidation.checkOptionsValid() sets
isOptionError=true when shouldHighlightValidData is on and the source
range exceeds LIST_WITH_COLOR_MAX_COUNT (2000 cells) — the highlight +
large source combo is unsupported. CLI previously had no signal for
this, so users only learned by seeing the dropdown render as
option-error in the workbook.
Add a Validate-phase stderr warning in +dropdown-set and +dropdown-update
when --source-range covers >2000 cells unless --highlight=false. Soft
warning, never blocks the request. Inline --options is not subject to
this limit — server enforces no count or per-item length cap on inline
lists, so no warning fires there.
* docs(sheets): sync lark-sheets skill from spec — dropdown flag descs reflect server reality
Pulls sheet-skill-spec canonical-spec → generated → consumers chain for
dropdown flag desc corrections committed upstream (Shortcut-flags base
table rows for +dropdown-set / +dropdown-update --options and
--source-range).
Aligns flag descs with byted-sheet behavior:
- --options: dropped fabricated "≤500 items, each ≤100 chars, no commas"
promise. byted-sheet ListOfItemValidation enforces none of these.
- --source-range: appended note about the only real cap —
LIST_WITH_COLOR_MAX_COUNT=2000 when --highlight is on (server flags the
dropdown as option-error beyond that; CLI warns at Validate time per
bb7ccae).
Also picks up an unrelated upstream tools-schema.json drift (chart float
block schema + data_validation.items description tweak) that surfaced
via npm run check:tool-schemas; bundling keeps the spec sync gate green.
* revert(sheets): drop tools-schema drift mirror from previous spec sync
930c9c7 顺带 sync 了 spec 的 tools-schema bundling — 跟那条 commit 一起
误带进来 chart float block required 和 data_validation.items 描述微调,
这两处其实是上游 sheet-ai-skills 还在 pending 的 revert。
配套 sheet-skill-spec 的 revert commit (a3aa9f2 on
fix/dropdown-flag-desc-real-limits / !11),重跑 sync:consumers 拉回
正确的 generated mirror:
- shortcuts/sheets/data/flag-schemas.json(chart 部分)
- skills/lark-sheets/references/lark-sheets-{chart,batch-update,write-cells}.md(rendered schema 段)
dropdown 文案改动(flag-defs.json 4 处 desc + dropdown 段的 reference
渲染)不在本 commit 范围,保持 930c9c7 的状态。
* docs(sheets): sync lark-sheets skill from spec — +filter-view-update --properties desc
去掉 +filter-view-update --properties 描述里"pass at least one of
--properties.rules / --range / --view-name"的误导承诺。--properties
实际是硬必填(MarkFlagRequired),且 update 走 PUT 整组覆盖语义。
* fix(sheets): align +cells-search/+cells-replace option keys with server schema
The CLI emitted `options.regex` and `options.include_formulas`, but the
server-side `search_data` / `replace_data` tool schemas declare and
consume `use_regex` and `match_formulas`. Result: passing `--regex` or
`--include-formulas` always failed with `unexpected property ... is not
defined in schema`.
Keep the user-facing flag names (`--regex`, `--include-formulas`) — only
the JSON keys sent to the server change. Updates the dry-run test that
locked the wrong contract.
* docs(sheets): sync float-image reference from spec — fix non-runnable examples
Two examples in skills/lark-sheets/references/lark-sheets-float-image.md
didn't actually run against PPE; sync brings them in line with CLI behavior:
- +float-image-create local-path example missed --image-name (CLI rejects
with `required flag(s) "image-name" not set` even when path basename
already has the filename). Add `--image-name "logo.png"` + inline note.
- +float-image-update "only change position" example missed image source
(CLI rejects with `one of --image, --image-token, or --image-uri is
required`). Expand to two steps: list with --jq pulls the current
image_token, then update re-passes --image-token to satisfy the guard.
- Leading warning realigned: image source is mandatory on every update
call; "keep original image" still requires passing the token explicitly.
Upstream change: sheet-skill-spec MR fix/float-image-reference-examples.
* feat: 同步 tools-schema.json 改动
* fix(sheets): allow +float-image-update to omit the image source
The image source (--image-token / --image-uri) is the only optional part
of an update: omit all of them to keep the current image. image_name,
position and size stay required — the manage_float_image tool rejects an
update without them, and +float-image-list does not return image_name to
backfill. Previously the shortcut forced an image source even when only
position/size changed, so those updates were rejected CLI-side before any
API call (reported as a Fail case in the sheets e2e rerun).
- floatImageProperties: gate the image-source requirement on create only;
keep image_name/position/size required on both; emit image_uri only when set
- sync flag-defs.json + lark-sheets-float-image.md from sheet-skill-spec
(image-name/position/size now required on +float-image-update)
- tests: cover the image-source-optional dry-run; the single-required checks
move to the +batch-update sub-op path (cobra owns the standalone path)
* docs(sheets): sync lark-sheets skill from spec
Mirror the canonical-spec reference fixes into the consumer skill:
- search_replace output contract: `matches[]` with `address` (+ `has_more`/`next_offset`)
- workbook sheet fields: `sheet_name`/`is_hidden`/`*_count`, no `frozen_*`
- `+range-fill` example uses a non-overlapping target (A3:A100)
- drop the unimplemented `envelope.meta.verification` auto-readback claim; advise
manual list/get verification instead
* fix(sheets): allow +pivot-create to omit both sheet selectors
manage_pivot_table_object treats sheet_id / sheet_name as the placement
target — when both are absent, handleCreate() auto-creates a new sub-sheet
to host the pivot table. The CLI's flag schema didn't reflect this:
- Exposed a third flag --target-sheet-id that mapped to the same wire
field as --sheet-id, leaving the caller unsure which one to use
- --sheet-id / --sheet-name had "XOR with the other" descriptions that
read like "operation context", so callers (especially LLM tool callers)
felt obligated to set one — frequently the source sheet — which
silently disabled the backend's auto-create guardrail and dropped the
pivot at A1, overlapping the source data
Wire change (synced from sheet-skill-spec): drop the duplicate
--target-sheet-id flag; rewrite --sheet-id / --sheet-name descriptions
to make the placement-target semantics explicit and call out that
omitting both is the recommended path.
Implementation change (this PR): add an at-most-one sheet-selector
helper and let object create-shortcuts opt into it.
- helpers.go: new optionalSheetSelector (both empty allowed; both set
still rejected; control-char validation unchanged). requireSheetSelector
is untouched — every existing caller keeps the exactly-one contract.
- lark_sheet_object_crud.go: objectCRUDSpec gains
allowEmptySheetSelectorOnCreate; objectCreateInput dispatches to
optionalSheetSelector when it's set. Only pivotSpec opts in;
chart / cond-format / sparkline / filter-view / float-image keep
the existing require semantics. DryRun and Execute switch to direct
flag extraction (same pattern Validate already used) so the XOR
check happens in exactly one place (the builder).
- pivotSpec: drop the enhanceCreateInput branch that read the now-removed
--target-sheet-id flag.
- Tests: TestPivotCreate_SheetSelectorSemantics covers both-empty /
both-set / single-set; TestObjectCreate_RequiresSheetSelector
regresses chart / cond-format / sparkline / filter-view to lock the
scope of the relaxation.
* docs(sheets): clarify filter/filter-view rules update is whole-set PUT
Synced from upstream tools-schema. The rules field on manage_filter_object and manage_filter_view_object now documents update as whole-set PUT semantics: submitted rules become the complete rule set, all existing columns' rules are cleared first, columns not listed lose their old rules (no merge), and [] clears everything. Description-only change, no structural/field change.
* refactor(sheets): switch dim-* / rows-cols-resize to A1-string range schema
The 9 row/column-region shortcuts used to share two int flags --start /
--end with inconsistent end semantics across commands — +dim-insert /
-delete / -hide / -unhide / -group / -ungroup treated --end as exclusive,
while +dim-move / +rows-resize / +cols-resize treated it as inclusive.
The skill reference even called this out as "the highest-frequency
off-by-one source", patched in docs rather than at the surface. Three
underlying tool schemas (position+count, A1 range string, 0-based int
pair) were all flattened onto the same --start/--end pair, which forced
a different normaliser per command and pushed mental math (count =
end - start) onto every caller.
Schema (sourced from base, regenerated via sheet-skill-spec, mirrored
into shortcuts/sheets/data/ and skills/lark-sheets/):
+dim-insert --position + --count
rows: "3"; columns: "C". --count rows/columns
inserted *before* --position.
+dim-delete / -hide / -unhide / -group / -ungroup
--range
+rows-resize / +cols-resize --range
A1 closed range. Rows: "3:7" or "5". Columns: "C:F" or "C".
Mixing letters and digits in one range is rejected.
+dim-move --source-range + --target
--target must match --source-range's dimension (both row or both
column). The move places the source block *before* --target.
Wire-shape preserved: modify_sheet_structure still receives `position`
+ `count` (insert) or a `range` A1 string (other dim-* ops); v3
move_dimension still receives 0-based inclusive ints (CLI parses the
A1 strings into them); resize_range still receives a two-sided A1
range (single-element form is expanded to "N:N" before send).
This is a flag-surface break (--start / --end / --dimension flags
removed from these 9 shortcuts); --dimension stays only on +dim-freeze
since it has no range to derive from.
Code: A1 parser added (parseA1Range / parseA1Position /
letterToColumnIndex reused from write_cells); dimRange / dimRangeFull /
dimPosition deleted; dim-move switches to source-range + target parsing;
resize gains a same-dimension guard so +rows-resize rejects "A:C" with
a clear "+rows-resize expects row numbers" message.
Tests: TestSheetStructureShortcuts_DryRun / TestDimMove_DryRun /
TestDimMove_Column / TestDimMove_MismatchedDimension /
TestDimRange_Validation / TestParseA1Range / TestResize_TypeAndSizeGuards
/ TestRangeOperationsShortcuts_DryRun all rewritten against the new
schema. Batch contract trio (BodyMatchesStandalone /
ErrorEquivalence / RejectsBadSubOpInput) and
TestBatchOp_DispatchCoversReportedBugs likewise. Full
`go test ./shortcuts/sheets/` passes.
* docs(sheets): sync +pivot-create placement reference from spec
Companion sync from sheet-skill-spec — the canonical reference rewrites
+pivot-create's "5 placement-related flags" rundown into a clearer
"4 placement-related flags" form (--target-sheet-id was already removed
in #1130, this updates the prose accordingly), and clarifies that
--sheet-id / --sheet-name on +pivot-create are the *placement* sheet
(not the source-data sheet), with omit-both as the strongly-recommended
default.
Also picks up a base-side --target-position description tweak that
dropped the now-stale "与 --target-sheet-id 配套" reference.
No CLI surface change.
* docs(sheets): sync +pivot-create summarize_by lowercase enum values from spec
* docs(sheets): wrap sheet names in single quotes in A1 examples
Synced from spec. Affects 3 reference md (pivot-table / batch-update /
write-cells) and 2 generated flag-data JSONs.
A1 examples like `Sheet1!A1:D100` now read `'Sheet1'!A1:D100` so models
default to single-quoted sheet names. Excel A1 notation requires single
quotes for sheet names containing hyphens / spaces / non-ASCII chars;
always-quoting is also valid for plain names, so this is the safer default
to teach.
Affected flags:
- +pivot-create --source
- +dropdown-update --ranges / --source-range
- +dropdown-delete --ranges
- +dropdown-set --source-range
- +cells-batch-set-style --ranges
- +cells-batch-clear --ranges
* docs(sheets): wrap A1 sheet names in handwritten examples + bash histexpand guide
Synced from spec. Affects 4 reference md (chart / pivot-table / sparkline /
write-cells) and SKILL.md.
In addition to wrapping sheet names in single quotes in all remaining
handwritten examples (covers chart refs.value / nameRef, sparkline source,
write-cells --source-range, pivot-create narrative), SKILL.md gains a new
"Shell quoting for A1 references with !" section.
The new section addresses bash history expansion: in interactive bash
(e.g., ShellExec sandbox), unescaped `!Word` after `"..."` triggers
`bash: !A1: event not found`, dropping the command before lark-cli sees
it. The section gives 4 quoting strategies (shell single-quote outer,
`set +H` prefix, mixed quoting, sheet-rename fallback) and an anti-pattern
list.
Affected files:
- skills/lark-sheets/SKILL.md (new section)
- skills/lark-sheets/references/lark-sheets-chart.md
- skills/lark-sheets/references/lark-sheets-pivot-table.md
- skills/lark-sheets/references/lark-sheets-sparkline.md
- skills/lark-sheets/references/lark-sheets-write-cells.md
* docs(sheets): drop bash histexpand section, fix write-cells table escape
Sync from spec, refining the bash-quoting deep-dive added in 0f695b6:
- Drop the `## Shell 调用注意事项` section in SKILL.md and the inline
`⚠️ bash 引号` callouts in lark-sheets-pivot-table.md and
lark-sheets-write-cells.md. The 4-scenario quoting table + anti-pattern
list turned out too verbose for the SKILL intro; single-quoted examples
in the references are themselves enough nudge.
- lark-sheets-write-cells.md L146: fix the table cell escape from the
malformed `'''Sheet1''!T1:T3'` (consecutive `''` are no-op empty
strings) to `''\''Sheet1'\''!T1:T3'`, matching the bash example at
L191 verbatim.
Net: 1 insertion, 40 deletions across 3 files.
* feat(sheets): rename +pivot-create sheet selector → --target-sheet-{id,name}
+pivot-create's placement selector (where the pivot table lands) is no
longer the generic --sheet-id / --sheet-name; it is now
--target-sheet-id / --target-sheet-name. The new names mark this as the
*output* sheet, distinct from the *data-source* sheet (which lives
inside --source as `'Sheet'!Range`). The other +pivot-{list,update,delete}
shortcuts keep --sheet-id / --sheet-name (their semantics are
"sheet that hosts the existing pivot", same as every other shortcut).
Motivation: an LLM agent reading the previous CLI surface saw +pivot-create
expose --sheet-id and assumed (as it had to) that it pointed at the data
source, like every other shortcut. The new flag name makes the intent
unambiguous at the call site, without relying on the agent having read
the narrative caveat in the reference doc.
Background: evaluation case U046 spent multiple rounds tripping on this
exact confusion before working around it with +sheet-rename.
Implementation:
- objectCRUDSpec gains createSheetIDFlag / createSheetNameFlag (with
default-fallback accessors sheetIDFlagOnCreate / sheetNameFlagOnCreate);
newObjectCreateShortcut + objectCreateInput consult the spec instead of
hard-coded "sheet-id" / "sheet-name". pivotSpec sets target-sheet-*;
every other create spec inherits the defaults.
- optionalSheetSelector (only used by pivot create) takes the two flag
names as parameters so its mutex / control-char errors quote the names
the user actually typed (--target-sheet-id, not --sheet-id).
- batch_op_dispatch: introduce sheetSelectorFlagsForSubOp(shortcut) →
(idFlag, nameFlag) returning target-sheet-* for "+pivot-create" and
the defaults otherwise; translateBatchOp uses it so +pivot-create
sub-ops in +batch-update accept the same renamed input keys.
- Tests:
- lark_sheet_object_crud_test.go: pivot-create cases switch args and
expected error wording to target-sheet-*; extra assertion that the
mutex error quotes the renamed flag (regression guard against
flag-name drift between code and error message).
- batch_op_contract_test.go: +pivot-create sub-op test uses
target-sheet-id / target-sheet-name input keys; the body-vs-standalone
contract loop reads the selector via sheetSelectorFlagsForSubOp so
every other shortcut keeps using sheet-id / sheet-name.
Synced reference docs (skills/lark-sheets/{SKILL.md,
references/lark-sheets-pivot-table.md}) mirror the spec's new flag names,
narrative, 3-placement-strategy block, and SKILL.md exception bullet that
explains why +pivot-create's badge says 无 sheet 定位 yet still has
placement selectors (just under different names).
flag-defs.json synced from spec picks up the renamed flags + kind=own.
All sheets-package tests pass.
* docs(sheets): strip migration-history language from pivot reference / SKILL
Synced from spec. Removes "renamed from / no longer called / not
--sheet-id" style migration-history language that snuck into the
previous sync. Reference and SKILL now describe the current flag names
directly without referencing the old names.
* docs(sheets): require +workbook-info before guessing sheet name
Synced from spec. SKILL.md adds a new rule under the sheet-locator
section: unless the user has explicitly named a sheet, the agent must
call +workbook-info first to fetch sheets[].sheet_id / sheets[].title
rather than guessing the default `Sheet1`. The Chinese-language tables
this CLI is typically used against rarely use that literal name —
"数据" / "Sheet" (no digit) / "工作表 1" / business-named sheets are
far more common — so guessing wastes a round-trip before the agent
ends up calling +workbook-info anyway.
The 统一调用范式 example also switches its `--sheet-name "Sheet1"`
placeholder to `<真实表名>` to remove the inadvertent suggestion that
`Sheet1` is a sensible default.
* docs(sheets): tell agent to `set +H` for A1 references containing `!`
Synced from spec. The sheet-locator section now warns: when a flag value
contains `!` (--source / --range / --ranges with a cross-sheet prefix),
run `set +H` at the start of the bash session to disable history
expansion — otherwise interactive bash (e.g. inside an agent's shell
sandbox) lexes "Sheet1!A1" as a history reference and fails with
`event not found` before lark-cli ever sees the argument.
When the sheet name itself contains hyphens / spaces / non-ASCII
characters, the A1 reference also needs single quotes around the sheet
name per A1 notation, e.g. --source "'Sales-2025'!A1:D100".
Also flips the previous `--range` example to `--range 'Sheet1!A1:B2'`
(shell single-quote) for consistency.
* feat(sheets): add schema-driven JSON flag validation
Validate composite JSON flags (--properties, --cells, --options,
--border-styles, --sort-keys) against the embedded flag-schemas.json
on every standalone and +batch-update sub-op invocation, replacing
ad-hoc per-shortcut guards.
Supports the JSON Schema subset actually used upstream: type / enum
/ oneOf / required / properties / items / nullable / minimum /
maximum / minItems / maxItems / additionalProperties (true | false
| <schema>). Enum errors quote the failing value, truncate beyond 8
entries, and surface case-only "did you mean" hints (SUM -> sum).
Coverage: 18 / 19 (shortcut, flag) pairs. +batch-update --operations
stays validator-skipped; its translator already does richer per
sub-op checks. mapFlagView.Command() routes batch sub-ops through
the same (command, flag) -> schema pipeline as standalone.
loadFlagSchemas() is now sync.Once-guarded so parallel first access
from t.Parallel test sets and concurrent shortcut invocations is
race-free.
Removes superseded hand-written guards:
- +pivot-create validateCreateInput / validatePivotCreateProps
- +range-sort sort-keys per-item shape check
Test fixtures updated to be schema-conformant (chart position/size,
pivot summarize_by lowercase, cells 2D-array shape).
* feat(sheets): add --rows-json output flag to +csv-get
+csv-get --rows-json returns structured rows ({row_number, values:{col→cell}})
instead of the CSV string, so callers can address cells by row_number / column
letter without parsing [row=N] or RFC-4180 CSV. Same read, alternate output
shape — a flag on +csv-get (default stays CSV), not a separate shortcut, since
the two differ only in representation.
- CsvGet.Execute: --rows-json reshapes the response via assembleRowsJSON
(parses annotated_csv into per-row records keyed by column letter; every
logical row emitted; embedded newlines parsed into cell values)
- surfaces the under-read hint structurally as data_not_fully_read
- flag-defs.json + read-data reference synced from spec
* feat(cli): agent-friendly errors, proxy silencing, +csv-put --range
Agent-experience fixes distilled from analyzing 50 real sheets
trajectories, where the top failures were hallucinated command/flag
names, proxy warnings corrupting JSON on stdout, and --range carried
over from +csv-get to +csv-put.
- did-you-mean: unify the duplicated Levenshtein into a shared
internal/suggest package and wire its prefix-weighted ranker into
unknown-subcommand and unknown-flag errors; flag-parse errors now
return a structured envelope with suggestions plus the full valid list,
so agents recover from semantic typos (e.g. --query vs --find).
- proxy: suppress the one-time proxy warning in non-interactive
(agent/CI/piped) runs so a 2>&1-merged stderr line cannot corrupt
stdout JSON; interactive sessions still warn.
- sheets +csv-put: accept --range as an alias for --start-cell (parity
with +csv-get / +cells-set) and echo the computed writes_range in
dry-run and the success envelope, so agents see the paste footprint
before it overwrites neighbours.
- docs(sheets): add an intent->command cheat-sheet to SKILL.md, a
runtime-prerequisites section, and document the --range alias and
writes_range behaviour.
* feat(sheets): close P0-4 pivot gaps — enum case, clear→pivot-delete hint, placement warning
Last open P0 from the 50-trajectory analysis — the two pivot black holes:
upper-cased summarize_by, and pivots built over the source sheet that hit
#REF! and then couldn't be removed.
- enum case tolerance: validateAgainstSchema rewrites a case-only enum
mismatch to the canonical (lower-case) spelling in place ("SUM" -> "sum")
before the request is sent, killing the whole class instead of only
hinting at it. Covers every nested enum (values[], calculated_fields[]);
genuinely unknown values still fail with the existing did-you-mean message.
- +cells-clear / +cells-batch-clear: when the backend reports "can not find
embedded block" (the range overlaps a pivot/chart), annotate the error
with the real fix — clearing cells can't delete an embedded object; remove
it with +pivot-delete / +chart-delete (id via +pivot-list / +chart-list).
Applied to both shortcuts, a Tips line, and the cells-clear reference.
- +pivot-create: a --help Tips block making "omit --target-* -> backend
auto-creates a sub-sheet, zero overwrite" the can't-miss default, plus a
placement_warning (dry-run + execute output) when an explicit target sheet
is set with no offset — definite when the target name matches the source
sheet, conditional otherwise. Local-only, advisory, never blocks the call.
The placement_warning is structured output, not a stderr line, so it
survives non-interactive proxy-warning silencing and isn't swallowed by 2>&1.
* feat(sheets): strip UTF-8 BOM from stdin/@file flag input
resolveInputFlags now strips a leading UTF-8 BOM from content read via stdin
or @file, so it cannot corrupt the first CSV cell or break JSON parsing of
payloads like --operations / --cells downstream.
Also pulls the synced lark-sheets skill docs from sheet-skill-spec and drops
scheme-number tags from two test comments.
* fix(sheets): drop dead --value-render-option flag from +csv-get
+csv-get wraps get_range_as_csv, which has no value_render_option support
(absent from its input type, executor, and published tool schema — it always
returns formatted display text via getText()). The CLI passed the flag through
as a silent no-op: callers asking for raw_value/formula got formatted values.
Remove the flag from flag-defs, drop the value_render_option passthrough in
csvGetInput, and clean the stale SKILL references. The real value_render_option
capability is unchanged on +cells-get (get_cell_ranges) via --include formula.
* chore: rename ppe x-tt-env lane to ppe_moa_canvas
* docs(sheets): sync skill description from spec (cloud-drive alias, lark-drive search, doubao routing)
* feat(sheets): restore pre-refactor shortcuts under backward/ for compatibility
The lark-sheets refactor renamed every shortcut (verb-noun → noun-verb,
e.g. +create-sheet → +sheet-create) and dropped the old commands. External
callers and the tests/cli_e2e/sheets suite still drive the legacy command
names (+create, +read, +write, +create-sheet, ...), which broke.
Re-add the pre-refactor implementations verbatim from main as an isolated
shortcuts/sheets/backward package (package rename only) and register
backward.Shortcuts() alongside sheets.Shortcuts(). Both sets mount under the
`sheets` service; their command names are fully disjoint (38 new vs 42 old,
zero overlap), so old and new commands coexist without collision.
* fix(sheets): resolve 30 golangci-lint v2.1.6 issues — copyloopvar, nilerr, unused
Removed 25 Go 1.22+ loop variable copies (copyloopvar) from test files where
tc := tc / tt := tt / c := c are no longer needed. Fixed 4 nilerr false
positives in flag_schema_validate.go by making intentional error discards
explicit (schema validation failures skip silently — best-effort guard).
Dropped unused batchOpDispatchKeys helper in batch_op_dispatch.go.
* feat(sheets): flag pre-refactor backward aliases via _notice and --help grouping
Nudge users whose lark-sheets skill predates the refactor to migrate off
the pre-refactor aliases (+read, +write, ...), without requiring anyone
to read --help.
- internal/deprecation: process-level pending Notice slot (mirrors
internal/skillscheck), surfaced in the JSON "_notice" envelope under a
"deprecated_command" key.
- internal/cmdutil: shared DeprecatedGroupID cobra group + helper so both
--help rendering and the unknown-subcommand path classify aliases the
same way.
- shortcuts/register.go: applySheetsCompatGroups splits the aliases into a
dedicated "update your skill" help group with "(-> +new)" pointers;
wrapSheetsBackwardDeprecation records the notice from Validate/Execute so
direct callers that never read --help still get flagged.
- cmd/root.go: extract composePendingNotice (now unit-testable) and split
availableSubcommandNames into current vs deprecated buckets while still
ranking unknown-subcommand suggestions across both.
* chore: drop hardcoded ppe lane routing from base security headers
The x-tt-env/x-use-ppe headers forced every request onto the
ppe_moa_canvas pre-release lane; they were only meant for exercising the
sheets refactor against the staging backend. Remove them so the CLI
routes to production by default.
* chore(sheets): promote lark-sheets skill to 2.0.0
Drop the -draft suffix now that the refactored sheets skill is ready to
ship.
* fix(sheets): correct +dropdown-get sheet-locator doc, finalize skill to 2.0.0
+dropdown-get requires a mandatory sheet selector — its Validate calls
resolveSheetSelector — so drop it from the "no sheet locator" exception
list in SKILL.md. It was wrongly grouped with +dropdown-update/+dropdown-delete,
which take only --ranges. +dropdown-get's own per-shortcut badge (公共四件套)
was already correct. Also finalize the skill version 2.0.0-draft -> 2.0.0.
* fix(sheets): enforce required-flag contract in batch sub-ops
Batch sub-ops reuse each shortcut's shared *Input builder through mapFlagView,
which seeds flag-defs defaults — so any required check that lives OUTSIDE the
builder (cobra MarkFlagsOneRequired, or a shortcut's own Validate) is silently
bypassed and the default value wins. Two gaps surfaced in PR review:
- +csv-put: with neither --start-cell nor --range set, start-cell's "A1"
default won and the paste silently anchored at A1. Require an explicit anchor
(guard on Changed, mirroring the standalone MarkFlagsOneRequired).
- +sheet-move: --index (plus >=0 bounds for index / source-index) was not
enforced in the batch path; a missing --index silently moved the sheet to the
front. Mirror SheetMove.Validate.
Also from the same review:
- +batch-update: an explicit --continue-on-error=false now wins over an
--operations envelope's continue_on_error:true (guard on Changed, not value).
- validateDropdownRanges rejects malformed sheet!range ("!A1", "Sheet1!",
"Sheet1!bad") at Validate instead of deferring to the server.
Tests added/updated for each path; full sheets suite green.
* fix(cli): surface skill in deprecated_command notice
deprecation.Notice carries Skill, but the _notice.deprecated_command payload
dropped it, forcing callers to parse `message` to learn which skill to update.
Emit `skill` when set, alongside the existing `replacement`.
* fix(sheets): harden batch type-checking and +workbook-create edge cases
From the branch code-review doc (3 findings):
- +batch-update sub-ops: `operations` is skipped by parse-time schema
validation and mapFlagView coerces a type-mismatched scalar to its zero
value, so "index":"abc" or "multiple":"true" silently became 0 / false and
wrote to the wrong place. translateBatchOp now runs validateRawTypes, which
checks each sub-op scalar against its flag-defs type and rejects mismatches.
- +workbook-create with empty arrays: buildInitialFillInput returned (nil,nil)
for empty rows while the caller wrote fill["excel_id"] unconditionally, so
--values '[]' panicked on a nil map and --headers '[]' produced an illegal
"A1:1" range. It now also returns nil when no cells survive (maxCols==0
guard) and Execute/DryRun skip the fill when fill==nil.
- +workbook-create partial failure: after the spreadsheet was created, a
first-sheet lookup or fill failure returned a bare fmt.Errorf, losing the new
token. It now returns a structured partial_success error carrying
spreadsheet_token in the detail so callers can retry or clean up.
Tests added for each path; sheets suite green.
* fix(cli): structured errors for unknown flags, print-schema, deprecated aliases
From the branch code-review doc (3 findings):
- pure-group UnknownFlags: installUnknownSubcommandGuard whitelists unknown
flags so a mistyped subcommand still reaches the suggestion path, but a lone
unknown flag before any subcommand (`sheets --badflag`) was swallowed and the
group fell through to help + exit 0. unknownSubcommandRunE now recovers the
swallowed tokens (from os.Args captured at Execute entry) and fails with a
structured unknown_flag error; a misplaced but known flag (e.g. --format)
still prints help.
- deprecated-alias notice: a backward-compat alias that fails a cobra-level
required flag short-circuits before RunE, so the Validate/Execute-wrapped
deprecation notice was dropped. Added Shortcut.OnInvoke, fired from PreRunE
(ahead of ValidateRequiredFlags); and the root legacy error fallback now
routes through the structured envelope when a deprecation is pending so the
migration hint survives. Non-deprecated errors keep the plain output.
- --print-schema: runShortcut returned the bare error from PrintFlagSchema. It
is now wrapped as a structured output.ExitError (type print_schema_error) so
agent introspection can parse the failure.
Tests added for each path; cmd + sheets suites green.
* fix(sheets): resolve --sheet-name via title + keep bare sheet selectors verbatim
Two review findings on the backward-compat layer:
- lookupSheetIndex matched only sm["sheet_name"], but get_workbook_structure
surfaces the sub-sheet display name as "title". Every --sheet-name path that
relies on the lookup (e.g. +sheet-move) failed to resolve. Fall back to
"title" when "sheet_name" is absent so either field resolves.
- +read / +write / +append fell back to --sheet-id when --range was omitted,
then routed that bare sheet id through the range normalizer. A sheet id that
looks A1-ish (letters+digits, e.g. "shtABC123") got mangled into
"shtABC123!shtABC123:shtABC123". Split the sheet-only path from the
range-normalization path: read/append pass the selector through verbatim,
write builds the rect from the selector's A1.
Regression tests added for both paths; sheets suite green.
* fix(sheets): silence nilerr/copyloopvar lint in batch type-check additions
- flag_view.go: annotate the fail-open return in validateRawTypes with
//nolint:nilerr (matches the repo convention for intentional fail-open).
- execute_paths_test.go: drop the redundant tc := tc copy (Go 1.22+ scopes
the loop var per iteration).
* test(sheets): data-driven required-flag parity contract for batch sub-ops
Adds TestBatchOp_RequiredFlagParity, the systematic standalone-vs-batch parity
check the branch review asked for. Data-driven over batchOpDispatch + flag-defs,
it asserts that for every batchable shortcut a +batch-update sub-op which
satisfies the sheet locator but omits the shortcut's business-required flags
fails in translateBatchOp, never silently defaulting.
This generalizes the hand-picked TestBatchOp_ErrorEquivalence / GuardsBeyondCobra
cases to the full 50-command surface and auto-covers shortcuts added later, so a
future refactor that moves a required check out of the shared *Input builder
(the failure mode behind the csv-put / sheet-move gaps) is caught here. 45
sub-tests run; locator-only commands (+sheet-delete / +sheet-hide / ...) have no
business-required flag to omit and are skipped. A missing-locator error is also
rejected so a bad fixture can't mask a real gap.
* refactor(sheets): drop unused int64 flag-type plumbing
No sheets flag-def declares an int64 type and RuntimeContext.Int64 had
zero callers, so remove the premature support: the RuntimeContext.Int64
helper, the registerShortcutFlagsWithContext int64 branch, the flagView
Int64 method + mapFlagView impl, and the typedDefault/validateRawTypes
int64 cases. float64 (consumed by --font-size) is kept.
* test(sheets): drop redundant copyloopvar copy in required-flag parity test
Go 1.22+ scopes the loop var per iteration, so `cmd, business := cmd, business`
in TestBatchOp_RequiredFlagParity is a no-op that trips the repo's copyloopvar
linter (same cleanup as 2132472). Behavior unchanged; 45 sub-tests still pass.
* revert(cli): drop non-interactive proxy-warning silencing
WarnIfProxied's interactivity gate is a generic CLI/agent-UX change
unrelated to the sheets refactor / backward-compat scope of this branch.
Split out to a dedicated PR; restore WarnIfProxied to its single-arg form
here (warn.go, warn_test.go, factory_default.go callers).
* docs(sheets): correct +workbook-info output field and batch +sheet-move index requirement
Sync from spec: +workbook-info returns sheet display name as 'title'
(sheet_name only as legacy fallback), and +sheet-move inside +batch-update
also requires --index, not just --sheet-id/--source-index.
* fix(sheets): reject non-integer numbers for batch int flags
validateRawTypes treated int and float64 identically (both only required a
JSON number), but mapFlagView.Int() truncates float64 via int(t), so a batch
sub-op accepted 1.9 for an int flag (e.g. --index) and silently floored it to
1. Standalone cobra rejects non-integer input for int flags at parse time;
enforce the same in the batch path with a math.Trunc check so batch/standalone
parity holds and positional fields can't land on a floored value.
* fix(cli): align flag-before-subcommand unknown_flag detail schema
The flag-before-subcommand recovery path emitted a Type: unknown_flag whose
detail only carried unknown_flags + command_path, diverging from
flagDidYouMean's unknown_flag detail (unknown, command_path, suggestions,
valid_flags). A consumer keyed on Type then saw two shapes for one Type.
Emit the same keys from both paths: add unknown (the offending flag; joined
when multiple), plus empty suggestions/valid_flags — the subcommand isn't
resolved at this point, so there is no meaningful flag universe to suggest
from, and the group's own flags would mislead. unknown_flags is retained as
the authoritative multi-flag field. Test locks the shared schema.
* perf(sheets): compile flag specs to Go to drop startup JSON parse
Every lark-cli invocation (sheets or not) unmarshaled data/flag-defs.json
(122KB) and data/flag-schemas.json (256KB) during package init, before
main(): flag-defs via the shortcut package vars (flagsFor runs at init),
flag-schemas via shortcuts.init() -> Shortcuts() -> commandsWithFlagSchema().
On a 0.5-core sandbox this cold-start cost lands on every command.
Compile both specs to Go at build time instead of parsing at runtime:
- flag-defs.json -> flag_defs_gen.go: flagDefs is a compiled map literal;
loadFlagDefs() returns it directly (no embed, no Unmarshal).
~3.3ms/4110 allocs -> ~0.57ms/539 allocs at sheets package init.
- flag-schemas.json -> flag_schemas_gen.go: only the command-name set
(commandsWithSchema) is compiled in; registration and the validate
fast-path gate on it without touching the 256KB blob. The blob stays
embedded and is unmarshaled lazily only on --print-schema or when
validating a command that has a schema. Removes the 256KB parse from
init entirely.
data/*.json remain the canonical source; *_gen.go are committed, derived
artifacts regenerated with `go generate ./shortcuts/sheets/...`
(shortcuts/sheets/internal/gen). *_gen_test.go guard source/generated drift.
No behavior change: flag rendering, required/enum/default, --print-schema,
and composite-flag schema validation verified unchanged; ./shortcuts/...
tests pass.
* ci(sheets): exempt internal/gen generators from forbidigo
The shortcuts/sheets/internal/gen code generator is a standalone
`package main` run via go:generate, not shortcut runtime code, so the
forbidigo bans on log.Fatal / os.ReadFile / fmt.Printf do not apply.
Making it "compliant" is impossible anyway: a structured error return
needs os.Exit (also banned), and the vfs alternative is blocked by
depguard shortcuts-no-vfs. Exempt shortcut internal/gen paths, matching
the existing _test.go and internal/vfs forbidigo exemptions.
* fix(cli): fail structured on flags before a missing subcommand
A pure group invoked with flags but no subcommand (e.g. `im --format=json`,
`sheets --format json`) silently fell through to help + exit 0, so an agent
could mistake a malformed call for success. The unknown-subcommand guard's
FParseErrWhitelist swallows the flags and leaves RunE with empty args; it now
recovers the raw flag tokens and fails structured:
- unknown flag(s) -> unknown_flag (unchanged)
- valid flag, no subcmd -> missing_subcommand (new, exit 2)
- bare group -> help, exit 0 (unchanged)
Because the group RunE is hook-wrapped, returning a real error also makes
plugin observers record the call as failed instead of ok (the lifecycle Err
is no longer flipped to nil).
Hardening from the same review:
- document the cobra error-text contract unknownFlagName relies on, in
both cmd/root.go and go.mod, so an i18n/reword is caught on upgrade.
- guard the reserved --print-schema/--flag-name registration with a Lookup
so a shortcut declaring same-named flags can't panic pflag.
Tests cover the new missing_subcommand path and the reserved-flag collision.
* fix(cli): don't flag group-valid globals as a missing subcommand
9f8dfa72 made a pure group invoked with flags but no subcommand fail with
missing_subcommand, keying on "any flag defined in the tree". That also matches
inherited global flags (--profile, ...), so `lark-cli --profile p im` and
`lark-cli im --profile p` errored with a misleading "flag --profile belongs to
a subcommand" instead of printing the group's help — a regression, since a bare
group carrying a global flag should print help.
Only treat a flag as missing_subcommand when it is valid on a subcommand but
not on the group itself or inherited (subcommandOnlyFlagTokens). A bare group
carrying only group-valid/global flags falls through to help; flags that
genuinely belong to an omitted subcommand (`im --format json`) still fail
structured, and unknown flags (`im --badflag`) still report unknown_flag.
Test covers a global flag on a bare group resolving to help.
---------
Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
This commit is contained in:
committed by
GitHub
parent
03a589978f
commit
b07a6003f9
@@ -57,6 +57,14 @@ linters:
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# internal/gen build-time generators (standalone `package main` run via
|
||||
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
|
||||
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
|
||||
# impossible here: a structured error return needs os.Exit (also banned),
|
||||
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
|
||||
- path: shortcuts/.*/internal/gen/
|
||||
linters:
|
||||
- forbidigo
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
|
||||
@@ -117,6 +117,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
|
||||
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
|
||||
// inherited by every subcommand, turning unknown-flag errors into a
|
||||
// structured "did you mean" envelope.
|
||||
rootCmd.SilenceUsage = true
|
||||
rootCmd.SetFlagErrorFunc(flagDidYouMean)
|
||||
|
||||
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
|
||||
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
const maxSuggestions = 3
|
||||
@@ -28,7 +29,7 @@ func suggestEventKeys(input string) []string {
|
||||
hits = append(hits, match{def.Key, 0})
|
||||
continue
|
||||
}
|
||||
if d := levenshtein(input, def.Key); d <= threshold {
|
||||
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
|
||||
hits = append(hits, match{def.Key, d})
|
||||
}
|
||||
}
|
||||
@@ -69,34 +70,3 @@ func unknownEventKeyErr(key string) error {
|
||||
"Run 'lark-cli event list' to see available keys.",
|
||||
)
|
||||
}
|
||||
|
||||
// levenshtein computes classic edit distance (two-row DP).
|
||||
func levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
@@ -10,27 +10,6 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"a", "", 1},
|
||||
{"", "abc", 3},
|
||||
{"kitten", "kitten", 0},
|
||||
{"kitten", "sitten", 1},
|
||||
{"kitten", "sitting", 3},
|
||||
{"飞书", "飞书", 0},
|
||||
{"飞书", "飞s", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := levenshtein(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestEventKeys(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
70
cmd/flag_suggest_test.go
Normal file
70
cmd/flag_suggest_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnknownFlagName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
name string
|
||||
ok bool
|
||||
}{
|
||||
{"unknown flag: --query", "query", true},
|
||||
{"unknown flag: --with-styles", "with-styles", true},
|
||||
{"unknown shorthand flag: 'z' in -z", "", false},
|
||||
{"flag needs an argument: --find", "", false},
|
||||
{`invalid argument "x" for "--count"`, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, ok := unknownFlagName(errors.New(c.in))
|
||||
if name != c.name || ok != c.ok {
|
||||
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
c.Flags().String("range", "", "")
|
||||
c.Flags().String("find", "", "")
|
||||
c.Flags().Bool("dry-run", false, "")
|
||||
|
||||
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--range") {
|
||||
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
valid, _ := detail["valid_flags"].([]string)
|
||||
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
|
||||
t.Errorf("valid_flags should list find & range, got %v", valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||
c := &cobra.Command{Use: "demo"}
|
||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "flag_error" {
|
||||
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
|
||||
}
|
||||
}
|
||||
61
cmd/notice_test.go
Normal file
61
cmd/notice_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
)
|
||||
|
||||
// composePendingNotice must surface a deprecated-command alias under the
|
||||
// "deprecated_command" key, with the migration target and a skill-update hint,
|
||||
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
|
||||
// without ever reading --help.
|
||||
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+read",
|
||||
Replacement: "+cells-get",
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
if got == nil {
|
||||
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
|
||||
}
|
||||
entry, ok := got["deprecated_command"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("missing deprecated_command key: %#v", got)
|
||||
}
|
||||
if entry["command"] != "+read" {
|
||||
t.Errorf("command = %v, want +read", entry["command"])
|
||||
}
|
||||
if entry["replacement"] != "+cells-get" {
|
||||
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
|
||||
}
|
||||
if entry["skill"] != "lark-sheets" {
|
||||
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
|
||||
}
|
||||
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
|
||||
t.Errorf("message missing skill-update hint: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing pending, the provider returns nil so no "_notice" field is
|
||||
// emitted on a clean run.
|
||||
func TestComposePendingNoticeEmpty(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
if got := composePendingNotice(); got != nil {
|
||||
// update/skills pending are process-global; only assert the absence of
|
||||
// our own key to stay robust against unrelated pending state.
|
||||
if _, ok := got["deprecated_command"]; ok {
|
||||
t.Fatalf("deprecated_command present after clear: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
377
cmd/root.go
377
cmd/root.go
@@ -18,14 +18,17 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
@@ -69,7 +72,15 @@ COMMUNITY:
|
||||
More help: lark-cli <command> --help`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
|
||||
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
|
||||
// from here. It stays nil in unit tests that invoke a RunE directly with
|
||||
// explicit args — correct, since those don't exercise the whitelist path.
|
||||
var rawInvocationArgs []string
|
||||
|
||||
func Execute() int {
|
||||
rawInvocationArgs = os.Args[1:]
|
||||
inv, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
@@ -133,29 +144,49 @@ func setupNotices() {
|
||||
skillscheck.Init(build.Version)
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
output.PendingNotice = composePendingNotice
|
||||
}
|
||||
|
||||
// composePendingNotice merges all process-level pending notices (available
|
||||
// update, skills/binary drift, deprecated-command alias) into the map surfaced
|
||||
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
|
||||
// Extracted from Execute so the composition is unit-testable.
|
||||
func composePendingNotice() map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
notice["skills"] = map[string]interface{}{
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if dep := deprecation.GetPending(); dep != nil {
|
||||
entry := map[string]interface{}{
|
||||
"command": dep.Command,
|
||||
"message": dep.Message(),
|
||||
"action": "lark-cli update",
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
}
|
||||
if dep.Skill != "" {
|
||||
entry["skill"] = dep.Skill
|
||||
}
|
||||
notice["deprecated_command"] = entry
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
return nil
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
@@ -260,6 +291,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return exitErr.Code
|
||||
}
|
||||
|
||||
// A backward-compat alias records its deprecation notice in PreRunE, which
|
||||
// runs before cobra's required-flag validation — but a missing required flag
|
||||
// fails before RunE and lands here, where the bare "Error:" line would drop
|
||||
// the notice. When a deprecation is pending, route through the structured
|
||||
// envelope so the migration hint still reaches the caller; all other errors
|
||||
// keep the existing plain output.
|
||||
if deprecation.GetPending() != nil {
|
||||
output.WriteErrorEnvelope(errOut, &output.ExitError{
|
||||
Code: 1,
|
||||
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
|
||||
}, string(f.ResolvedIdentity))
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintln(errOut, "Error:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -301,6 +345,12 @@ func asExitError(err error) *output.ExitError {
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
// Route an unknown subcommand to unknownSubcommandRunE even when flags
|
||||
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
|
||||
// consumes no flags itself, so unknown flags belong to the (missing)
|
||||
// subcommand; whitelisting them here prevents cobra from erroring on the
|
||||
// flag first and printing usage instead of our structured suggestion.
|
||||
cmd.FParseErrWhitelist.UnknownFlags = true
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
@@ -320,14 +370,89 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
// they have moved to the typed surface.
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
|
||||
// like the global --profile, legitimately prints help. But a flag that
|
||||
// belongs to a (missing) subcommand is a user error: the guard's
|
||||
// FParseErrWhitelist swallows such flags and leaves args empty, so without
|
||||
// the checks below they would silently fall through to help + exit 0 —
|
||||
// letting an agent mistake a malformed call (`im --format json`,
|
||||
// `sheets --badflag`) for success. Recover the swallowed tokens from the
|
||||
// raw invocation and fail structured instead.
|
||||
flags := flagTokensInArgs(rawInvocationArgs)
|
||||
if len(flags) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
|
||||
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
// Keep the same detail keys as flagDidYouMean's unknown_flag
|
||||
// so a consumer keyed on Type can read a stable shape. The
|
||||
// subcommand isn't resolved here, so suggestions/valid_flags
|
||||
// have no meaningful universe to draw from — emit empty
|
||||
// rather than the group's own (misleading) flags. unknown is
|
||||
// the back-compat singular field; unknown_flags carries the
|
||||
// full list when more than one flag was supplied.
|
||||
"unknown": strings.Join(unknown, ", "),
|
||||
"unknown_flags": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": []string{},
|
||||
"valid_flags": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
// The remaining flags are all defined somewhere in the tree. Those valid
|
||||
// on the group itself or inherited (e.g. the global --profile) do not
|
||||
// require a subcommand, so a bare group carrying only those still prints
|
||||
// help. Anything left belongs to a subcommand that was omitted
|
||||
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
|
||||
// real, the subcommand is what's missing.
|
||||
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
|
||||
if len(misplaced) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_subcommand",
|
||||
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
|
||||
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
|
||||
Detail: map[string]any{
|
||||
"command_path": cmd.CommandPath(),
|
||||
"flags": misplaced,
|
||||
"suggestions": []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
available, deprecated := availableSubcommandNames(cmd)
|
||||
// Rank suggestions across both current and deprecated names so a mistyped
|
||||
// legacy command (e.g. +raed → +read) still resolves; the alias stays
|
||||
// runnable and self-flags via the _notice on execution.
|
||||
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
if len(suggestions) > 0 {
|
||||
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
|
||||
strings.Join(suggestions, ", "), cmd.CommandPath())
|
||||
}
|
||||
detail := map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"available": available,
|
||||
}
|
||||
// Only services with backward-compat aliases (currently sheets) carry a
|
||||
// deprecated bucket; omit the key elsewhere so every other service's
|
||||
// envelope is unchanged.
|
||||
if len(deprecated) > 0 {
|
||||
detail["deprecated"] = deprecated
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
@@ -335,17 +460,114 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
|
||||
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
|
||||
// defined is not considered (see unknownFlagTokens for that). A pure group
|
||||
// with any flag token but no subcommand is a user error — a pure group
|
||||
// consumes no flags of its own, so the flag must belong to a subcommand — so
|
||||
// the caller fails structured instead of falling through to help.
|
||||
func flagTokensInArgs(rawArgs []string) []string {
|
||||
var toks []string
|
||||
for _, a := range rawArgs {
|
||||
if a == "--" {
|
||||
break // everything after -- is positional
|
||||
}
|
||||
if len(a) < 2 || a[0] != '-' {
|
||||
continue
|
||||
}
|
||||
toks = append(toks, a)
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
|
||||
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
|
||||
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
|
||||
// the suggestion path; the side effect is that flags before a subcommand are
|
||||
// swallowed. This recovers the genuinely-unknown ones so the caller can name
|
||||
// them in a "did you mean" envelope.
|
||||
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var unknown []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name != "" && !flagDefinedInTree(cmd, name) {
|
||||
unknown = append(unknown, a)
|
||||
}
|
||||
}
|
||||
return unknown
|
||||
}
|
||||
|
||||
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
|
||||
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
|
||||
// group and therefore not requiring a subcommand.
|
||||
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
lookup := func(fs *pflag.FlagSet) bool {
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
|
||||
}
|
||||
|
||||
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
|
||||
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
|
||||
// omitting the subcommand they belong to (`im --format json`). Global flags
|
||||
// valid on the bare group (e.g. --profile) are excluded so
|
||||
// `lark-cli --profile p im` still prints help rather than erroring.
|
||||
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
|
||||
var misplaced []string
|
||||
for _, a := range flagTokensInArgs(rawArgs) {
|
||||
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
|
||||
if name == "" || flagKnownOnGroup(cmd, name) {
|
||||
continue
|
||||
}
|
||||
if flagDefinedInTree(cmd, name) {
|
||||
misplaced = append(misplaced, a)
|
||||
}
|
||||
}
|
||||
return misplaced
|
||||
}
|
||||
|
||||
// flagDefinedInTree reports whether name is defined on cmd, its inherited
|
||||
// (persistent) flags, or any direct subcommand. The subcommand case covers a
|
||||
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
|
||||
// --format is injected on every leaf shortcut, not on the group — so only a
|
||||
// genuinely unknown flag like `sheets --badflag` is reported.
|
||||
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
|
||||
short := len(name) == 1
|
||||
known := func(c *cobra.Command, inherited bool) bool {
|
||||
fs := c.Flags()
|
||||
if inherited {
|
||||
fs = c.InheritedFlags()
|
||||
}
|
||||
if short {
|
||||
return fs.ShorthandLookup(name) != nil
|
||||
}
|
||||
return fs.Lookup(name) != nil
|
||||
}
|
||||
if known(cmd, false) || known(cmd, true) {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if known(c, false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// availableSubcommandNames returns the invokable subcommand names of cmd, split
|
||||
// into current commands and backward-compatibility aliases (those tagged into
|
||||
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
|
||||
// sorted; hidden commands plus help/completion are omitted.
|
||||
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
@@ -354,10 +576,95 @@ func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
if cmdutil.IsDeprecatedCommand(c) {
|
||||
deprecated = append(deprecated, name)
|
||||
} else {
|
||||
available = append(available, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
sort.Strings(available)
|
||||
sort.Strings(deprecated)
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
|
||||
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
|
||||
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
|
||||
// --find, where edit distance alone finds nothing). Other flag errors stay
|
||||
// structured but generic.
|
||||
func flagDidYouMean(c *cobra.Command, ferr error) error {
|
||||
name, isUnknown := unknownFlagName(ferr)
|
||||
if !isUnknown {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "flag_error",
|
||||
Message: ferr.Error(),
|
||||
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
|
||||
},
|
||||
}
|
||||
}
|
||||
valid := visibleFlagNames(c)
|
||||
suggestions := suggest.Closest(name, valid, 3)
|
||||
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
|
||||
if len(suggestions) > 0 {
|
||||
for i := range suggestions {
|
||||
suggestions[i] = "--" + suggestions[i]
|
||||
}
|
||||
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
|
||||
strings.Join(suggestions, ", "), c.CommandPath())
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_flag",
|
||||
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": "--" + name,
|
||||
"command_path": c.CommandPath(),
|
||||
"suggestions": suggestions,
|
||||
"valid_flags": valid,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
|
||||
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
|
||||
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
|
||||
// those structured but generic — hallucinated flags are essentially always long.
|
||||
//
|
||||
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
|
||||
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
|
||||
// silently fails and unknown flags degrade to a generic flag_error — re-verify
|
||||
// this prefix when bumping cobra.
|
||||
func unknownFlagName(err error) (string, bool) {
|
||||
const p = "unknown flag: --"
|
||||
msg := err.Error()
|
||||
i := strings.Index(msg, p)
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := msg[i+len(p):]
|
||||
if j := strings.IndexAny(rest, " \t"); j >= 0 {
|
||||
rest = rest[:j]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
|
||||
// the valid_flags detail).
|
||||
func visibleFlagNames(c *cobra.Command) []string {
|
||||
var names []string
|
||||
c.Flags().VisitAll(func(f *pflag.Flag) {
|
||||
if !f.Hidden {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
})
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
@@ -268,6 +269,54 @@ func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
|
||||
// backward-compat alias that fails on a cobra-level required flag (which
|
||||
// short-circuits before RunE) still routes through the structured envelope,
|
||||
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
|
||||
// switches to WriteErrorEnvelope when a deprecation is pending — so the
|
||||
// migration notice is no longer dropped on the plain "Error:" line.
|
||||
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
deprecation.SetPending(&deprecation.Notice{
|
||||
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
|
||||
})
|
||||
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
|
||||
// nor an *output.ExitError, so it reaches the legacy fallback.
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
|
||||
out := errOut.String()
|
||||
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
|
||||
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
|
||||
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
|
||||
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
|
||||
// fix does not reshape every unrecognized cobra error.
|
||||
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
|
||||
if !strings.HasPrefix(errOut.String(), "Error:") {
|
||||
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,149 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFlagTokens(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
|
||||
// the subcommand) is distinguished from a genuinely unknown one.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rawArgs []string
|
||||
want []string
|
||||
}{
|
||||
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
|
||||
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
|
||||
{"no flags at all", []string{"drive"}, nil},
|
||||
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
|
||||
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := unknownFlagTokens(drive, tc.rawArgs)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
|
||||
// --badflag, so RunE sees no args; the guard must recover it from
|
||||
// rawInvocationArgs and fail structured rather than print help + exit 0.
|
||||
rawInvocationArgs = []string{"drive", "--badflag"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
|
||||
}
|
||||
|
||||
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
|
||||
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_flag" {
|
||||
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "--badflag" {
|
||||
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
|
||||
}
|
||||
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
|
||||
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
|
||||
}
|
||||
for _, key := range []string{"suggestions", "valid_flags"} {
|
||||
if _, present := detail[key]; !present {
|
||||
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
// --query is defined on the +search subcommand, so it is a *valid* flag that
|
||||
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
|
||||
// must still fail structured (missing_subcommand) rather than fall through to
|
||||
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
|
||||
for _, c := range drive.Commands() {
|
||||
if c.Name() == "+search" {
|
||||
c.Flags().String("query", "", "")
|
||||
}
|
||||
}
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"drive", "--query", "x"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
err := drive.RunE(drive, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
|
||||
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
|
||||
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
// A bare group carrying only a group-valid global flag (e.g. the inherited
|
||||
// --profile) is not missing a subcommand — those flags do not belong to a
|
||||
// subcommand — so it must print help, not fail with missing_subcommand.
|
||||
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
rawInvocationArgs = []string{"--profile", "p", "drive"}
|
||||
t.Cleanup(func() { rawInvocationArgs = nil })
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
@@ -113,11 +257,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
|
||||
// back to pointing at --help; the full machine-readable list lives in
|
||||
// detail.available below (which also excludes hidden commands).
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--help") {
|
||||
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
@@ -164,7 +308,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
got, _ := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
@@ -175,3 +319,61 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
available, deprecated := availableSubcommandNames(root)
|
||||
if len(available) != 1 || available[0] != "+new-cmd" {
|
||||
t.Errorf("available = %v, want [+new-cmd]", available)
|
||||
}
|
||||
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
|
||||
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
// unknownSubcommandRunE must split current vs deprecated subcommands into
|
||||
// separate detail buckets, while suggestions still rank across both so a
|
||||
// mistyped legacy alias resolves.
|
||||
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
|
||||
svc.AddCommand(
|
||||
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
err := unknownSubcommandRunE(svc, []string{"+reat"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
|
||||
}
|
||||
|
||||
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
|
||||
t.Errorf("available = %v, want [+cells-get]", available)
|
||||
}
|
||||
deprecated, ok := detail["deprecated"].([]string)
|
||||
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
|
||||
t.Errorf("deprecated = %v, want [+read]", deprecated)
|
||||
}
|
||||
// suggestions rank across both buckets: "+reat" is closest to +read.
|
||||
suggestions, _ := detail["suggestions"].([]string)
|
||||
found := false
|
||||
for _, s := range suggestions {
|
||||
if s == "+read" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -14,7 +14,7 @@ require (
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmdpolicy
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/suggest"
|
||||
)
|
||||
|
||||
// suggestRisk returns the closest valid Risk literal by edit distance
|
||||
@@ -20,9 +21,9 @@ func suggestRisk(bad string) string {
|
||||
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
|
||||
}
|
||||
best := string(candidates[0])
|
||||
bestDist := levenshtein(lowered, best)
|
||||
bestDist := suggest.Levenshtein(lowered, best)
|
||||
for _, c := range candidates[1:] {
|
||||
if d := levenshtein(lowered, string(c)); d < bestDist {
|
||||
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
|
||||
bestDist, best = d, string(c)
|
||||
}
|
||||
}
|
||||
@@ -40,47 +41,3 @@ func toLower(s string) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// levenshtein computes the classic edit distance between two strings.
|
||||
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
|
||||
// makes raw performance irrelevant — clarity beats trickiness here.
|
||||
func levenshtein(a, b string) int {
|
||||
if len(a) == 0 {
|
||||
return len(b)
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return len(a)
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
curr := make([]int, len(b)+1)
|
||||
for j := 0; j <= len(b); j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(a); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(b); j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min3(
|
||||
prev[j]+1, // deletion
|
||||
curr[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(b)]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
m := a
|
||||
if b < m {
|
||||
m = b
|
||||
}
|
||||
if c < m {
|
||||
m = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -29,23 +29,3 @@ func TestSuggestRisk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "", 0},
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"wrtie", "write", 2},
|
||||
{"kitten", "sitting", 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := levenshtein(c.a, c.b)
|
||||
if got != c.want {
|
||||
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
internal/cmdutil/groups.go
Normal file
18
internal/cmdutil/groups.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
|
||||
// command — one kept alive for users whose skill predates a refactor. Service
|
||||
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
|
||||
// rendering and unknown-subcommand suggestions read it to separate these
|
||||
// aliases from the current commands.
|
||||
const DeprecatedGroupID = "deprecated"
|
||||
|
||||
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
|
||||
func IsDeprecatedCommand(c *cobra.Command) bool {
|
||||
return c != nil && c.GroupID == DeprecatedGroupID
|
||||
}
|
||||
57
internal/deprecation/deprecation.go
Normal file
57
internal/deprecation/deprecation.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package deprecation carries a process-level notice that the command currently
|
||||
// being executed is a backward-compatibility alias, kept alive for users whose
|
||||
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
|
||||
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
|
||||
//
|
||||
// A CLI process runs exactly one shortcut, so a single process-level slot is
|
||||
// sufficient: the command's Execute records the notice before producing output,
|
||||
// and the output layer reads it back when building the envelope.
|
||||
package deprecation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Notice describes a deprecated command alias and the current command that
|
||||
// replaces it. Replacement and Skill are optional.
|
||||
type Notice struct {
|
||||
Command string `json:"command"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns a single-line, AI-agent-parseable description of the alias
|
||||
// plus the canonical fix (update the skill). Mirrors the style of
|
||||
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
|
||||
func (n *Notice) Message() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
if n.Replacement != "" {
|
||||
b.WriteString("; use ")
|
||||
b.WriteString(n.Replacement)
|
||||
b.WriteString(" instead")
|
||||
}
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pending stores the latest deprecation notice for the current process.
|
||||
var pending atomic.Pointer[Notice]
|
||||
|
||||
// SetPending stores the notice for consumption by output decorators.
|
||||
// Pass nil to clear.
|
||||
func SetPending(n *Notice) { pending.Store(n) }
|
||||
|
||||
// GetPending returns the pending deprecation notice, or nil.
|
||||
func GetPending() *Notice { return pending.Load() }
|
||||
58
internal/deprecation/deprecation_test.go
Normal file
58
internal/deprecation/deprecation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
notice Notice
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "replacement and skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no replacement",
|
||||
notice: Notice{Command: "+read", Skill: "lark-sheets"},
|
||||
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
|
||||
},
|
||||
{
|
||||
name: "no skill",
|
||||
notice: Notice{Command: "+read", Replacement: "+cells-get"},
|
||||
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.notice.Message(); got != tt.want {
|
||||
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetPending(t *testing.T) {
|
||||
t.Cleanup(func() { SetPending(nil) })
|
||||
|
||||
SetPending(nil)
|
||||
if got := GetPending(); got != nil {
|
||||
t.Fatalf("expected nil pending after clear, got %#v", got)
|
||||
}
|
||||
|
||||
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
|
||||
SetPending(n)
|
||||
got := GetPending()
|
||||
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
|
||||
t.Fatalf("GetPending() = %#v, want %#v", got, n)
|
||||
}
|
||||
|
||||
SetPending(nil)
|
||||
if GetPending() != nil {
|
||||
t.Fatal("expected nil after clearing")
|
||||
}
|
||||
}
|
||||
104
internal/suggest/suggest.go
Normal file
104
internal/suggest/suggest.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package suggest provides the shared "did you mean" primitives: a rune-aware
|
||||
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
|
||||
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
|
||||
// carrying their own copy.
|
||||
package suggest
|
||||
|
||||
import "sort"
|
||||
|
||||
// Levenshtein computes the classic edit distance between two strings. It is
|
||||
// rune-aware, so it is correct for multi-byte input.
|
||||
func Levenshtein(a, b string) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
if len(ra) == 0 {
|
||||
return len(rb)
|
||||
}
|
||||
if len(rb) == 0 {
|
||||
return len(ra)
|
||||
}
|
||||
prev := make([]int, len(rb)+1)
|
||||
curr := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
// Closest returns up to maxN of candidates that plausibly match typed, ranked
|
||||
// by shared-prefix length (desc) then edit distance (asc), keeping only
|
||||
// reasonably-close ones.
|
||||
//
|
||||
// Shared prefix is weighted first on purpose: hallucinated names are often
|
||||
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
|
||||
// "--with-styles" vs nothing close), where the common prefix is the strongest
|
||||
// signal of intent that raw edit distance misses.
|
||||
func Closest(typed string, candidates []string, maxN int) []string {
|
||||
type scored struct {
|
||||
name string
|
||||
prefix int
|
||||
dist int
|
||||
}
|
||||
limit := editLimit(typed)
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
p := sharedPrefixLen(typed, c)
|
||||
d := Levenshtein(typed, c)
|
||||
// Keep only plausible matches: a meaningful shared prefix, or an edit
|
||||
// distance within budget. Drop everything else so the hint stays short.
|
||||
if p >= 3 || d <= limit {
|
||||
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
|
||||
}
|
||||
}
|
||||
sort.Slice(ranked, func(i, j int) bool {
|
||||
if ranked[i].prefix != ranked[j].prefix {
|
||||
return ranked[i].prefix > ranked[j].prefix
|
||||
}
|
||||
if ranked[i].dist != ranked[j].dist {
|
||||
return ranked[i].dist < ranked[j].dist
|
||||
}
|
||||
return ranked[i].name < ranked[j].name
|
||||
})
|
||||
if maxN <= 0 || maxN > len(ranked) {
|
||||
maxN = len(ranked)
|
||||
}
|
||||
out := make([]string, 0, maxN)
|
||||
for _, s := range ranked[:maxN] {
|
||||
out = append(out, s.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// editLimit allows roughly one third of the typed length in edits (min 2), so
|
||||
// short names tolerate a couple of typos and longer ones proportionally more.
|
||||
func editLimit(s string) int {
|
||||
if l := len([]rune(s)) / 3; l > 2 {
|
||||
return l
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func sharedPrefixLen(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
n := 0
|
||||
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
74
internal/suggest/suggest_test.go
Normal file
74
internal/suggest/suggest_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
|
||||
cmds := []string{
|
||||
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
|
||||
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
|
||||
"+pivot-create", "+sheet-info",
|
||||
}
|
||||
// "+cells-find" is semantically +cells-search but lexically far; the shared
|
||||
// "+cells-" prefix should still surface the right family (incl. +cells-search).
|
||||
got := Closest("+cells-find", cmds, 6)
|
||||
if len(got) == 0 || len(got) > 6 {
|
||||
t.Fatalf("expected 1..6 suggestions, got %v", got)
|
||||
}
|
||||
if !slices.Contains(got, "+cells-search") {
|
||||
t.Errorf("expected +cells-search among suggestions, got %v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if len(s) < 7 || s[:7] != "+cells-" {
|
||||
t.Errorf("suggestion %q does not share the +cells- prefix", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
|
||||
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
|
||||
if len(got) == 0 || got[0] != "+cells-get" {
|
||||
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosest_NoPlausibleMatch(t *testing.T) {
|
||||
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
|
||||
t.Errorf("expected no suggestions for unrelated input, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"", "abc", 3},
|
||||
{"abc", "", 3},
|
||||
{"abc", "abc", 0},
|
||||
{"kitten", "sitting", 3},
|
||||
{"cell-get", "cells-get", 1},
|
||||
{"--query", "--find", 5},
|
||||
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
|
||||
{"飞书", "飞s", 1}, // one rune substitution, not byte count
|
||||
}
|
||||
for _, c := range cases {
|
||||
if d := Levenshtein(c.a, c.b); d != c.want {
|
||||
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPrefixLen(t *testing.T) {
|
||||
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 7", got)
|
||||
}
|
||||
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
|
||||
t.Errorf("sharedPrefixLen = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// RuntimeContext provides helpers for shortcut execution.
|
||||
@@ -72,6 +73,16 @@ func (ctx *RuntimeContext) IsBot() bool {
|
||||
return ctx.As().IsBot()
|
||||
}
|
||||
|
||||
// Command returns the shortcut command name as cobra knows it (e.g.
|
||||
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
|
||||
// validation) that key off the shortcut identity.
|
||||
func (ctx *RuntimeContext) Command() string {
|
||||
if ctx.Cmd == nil {
|
||||
return ""
|
||||
}
|
||||
return ctx.Cmd.Name()
|
||||
}
|
||||
|
||||
// UserOpenId returns the current user's open_id from config.
|
||||
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
|
||||
@@ -200,6 +211,12 @@ func (ctx *RuntimeContext) Int(name string) int {
|
||||
return v
|
||||
}
|
||||
|
||||
// Float64 returns a float64 flag value (non-integer numbers).
|
||||
func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
v, _ := ctx.Cmd.Flags().GetFloat64(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -938,6 +955,29 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
}
|
||||
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
|
||||
onInvoke := shortcut.OnInvoke
|
||||
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
|
||||
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
|
||||
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
|
||||
// surface even when the call later fails on a missing required flag.
|
||||
// - --print-schema: pure local introspection; relax the required-flag
|
||||
// gate so callers don't fill in unrelated flags just to ask for a
|
||||
// schema (clearing the annotation here is the supported opt-out).
|
||||
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
|
||||
if onInvoke != nil {
|
||||
onInvoke()
|
||||
}
|
||||
if relaxRequiredForSchema {
|
||||
if want, _ := c.Flags().GetBool("print-schema"); want {
|
||||
c.Flags().VisitAll(func(fl *pflag.Flag) {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
|
||||
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
|
||||
cmdutil.SetTips(cmd, shortcut.Tips)
|
||||
@@ -951,6 +991,31 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
// only registered when the shortcut opts in via PrintFlagSchema.
|
||||
if s.PrintFlagSchema != nil {
|
||||
if want, _ := cmd.Flags().GetBool("print-schema"); want {
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// structured ExitError so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if _, ok := err.(*output.ExitError); !ok {
|
||||
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.Out, string(out))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
as, err := resolveShortcutIdentity(cmd, f, s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1055,6 +1120,16 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
return rctx, nil
|
||||
}
|
||||
|
||||
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
|
||||
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
|
||||
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
|
||||
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
|
||||
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
|
||||
// occurrences are left untouched.
|
||||
func stripUTF8BOM(s string) string {
|
||||
return strings.TrimPrefix(s, "\uFEFF")
|
||||
}
|
||||
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
@@ -1089,7 +1164,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
|
||||
// cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1116,7 +1193,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
WithParam("--" + fl.Name).
|
||||
WithCause(err)
|
||||
}
|
||||
rctx.Cmd.Flags().Set(fl.Name, string(data))
|
||||
// strip a leading UTF-8 BOM so it
|
||||
// can't corrupt the first CSV cell or break JSON parsing downstream.
|
||||
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -1203,6 +1282,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d int
|
||||
fmt.Sscanf(fl.Default, "%d", &d)
|
||||
cmd.Flags().Int(fl.Name, d, desc)
|
||||
case "float64":
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
@@ -1237,6 +1320,17 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if s.PrintFlagSchema != nil {
|
||||
// Guard against a shortcut that already declares these reserved
|
||||
// introspection flags: pflag panics on a duplicate registration.
|
||||
// Mirrors the Lookup guard on --format above.
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
|
||||
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,46 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
|
||||
// --print-schema / --flag-name flags are registered defensively: a shortcut
|
||||
// that already declares same-named flags must not trigger pflag's duplicate-
|
||||
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
|
||||
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
shortcut := Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+introspect",
|
||||
Description: "x",
|
||||
// The shortcut's own flags collide with the names the runner auto-
|
||||
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
|
||||
Flags: []Flag{
|
||||
{Name: "print-schema", Desc: "user-defined collision"},
|
||||
{Name: "flag-name", Desc: "user-defined collision"},
|
||||
},
|
||||
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
|
||||
}
|
||||
}()
|
||||
shortcut.Mount(parent, f)
|
||||
|
||||
cmd, _, err := parent.Find([]string{"+introspect"})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
if cmd.Flags().Lookup("print-schema") == nil {
|
||||
t.Error("print-schema flag should still exist after the guarded registration")
|
||||
}
|
||||
if cmd.Flags().Lookup("flag-name") == nil {
|
||||
t.Error("flag-name flag should still exist after the guarded registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
|
||||
@@ -221,3 +221,53 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
cases := []struct{ name, in, want string }{
|
||||
{"leading BOM removed", "\uFEFFhello", "hello"},
|
||||
{"no BOM unchanged", "hello", "hello"},
|
||||
{"empty unchanged", "", ""},
|
||||
{"only BOM becomes empty", "\uFEFF", ""},
|
||||
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
|
||||
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := stripUTF8BOM(c.in); got != c.want {
|
||||
t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
|
||||
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
|
||||
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
|
||||
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
|
||||
t.Errorf("leading BOM not stripped from stdin, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
|
||||
// with "invalid character 'ï'".
|
||||
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
|
||||
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
|
||||
|
||||
if err := resolveInputFlags(rctx, flags); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
|
||||
t.Errorf("leading BOM not stripped from file, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
@@ -58,6 +58,29 @@ type Shortcut struct {
|
||||
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
|
||||
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
|
||||
|
||||
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
|
||||
// cobra validates required flags — so its side effect fires even when the
|
||||
// call later fails on a missing required flag (which short-circuits before
|
||||
// Validate/Execute). The backward-compat aliases use it to record a
|
||||
// deprecation notice that must surface regardless of whether the call
|
||||
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
|
||||
OnInvoke func()
|
||||
|
||||
// PrintFlagSchema, when non-nil, opts this shortcut into the
|
||||
// `--print-schema --flag-name <name>` runtime introspection contract.
|
||||
// The framework auto-injects those two system flags and short-circuits
|
||||
// Validate/Execute when --print-schema is set, dispatching to this hook.
|
||||
//
|
||||
// Contract:
|
||||
// - flagName == "" → list the flags this shortcut can describe
|
||||
// (output is impl-defined; agents read this to
|
||||
// discover which flags are introspectable).
|
||||
// - flagName == "...": → return the JSON Schema (or schema-like blob)
|
||||
// for that flag.
|
||||
// Return value is written to stdout verbatim; callers typically format
|
||||
// it as JSON. Returning an error surfaces as a normal command error.
|
||||
PrintFlagSchema func(flagName string) ([]byte, error)
|
||||
|
||||
// PostMount is an optional hook called after the cobra.Command is fully
|
||||
// configured (flags registered, tips set) and after parent.AddCommand(cmd)
|
||||
// has attached it to the parent. Use it to install custom help functions or
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/apps"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/markdown"
|
||||
"github.com/larksuite/cli/shortcuts/minutes"
|
||||
"github.com/larksuite/cli/shortcuts/sheets"
|
||||
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
|
||||
"github.com/larksuite/cli/shortcuts/slides"
|
||||
"github.com/larksuite/cli/shortcuts/task"
|
||||
"github.com/larksuite/cli/shortcuts/vc"
|
||||
@@ -64,6 +66,11 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, im.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
|
||||
// Backward-compatible sheets shortcuts (pre-refactor command names),
|
||||
// kept under shortcuts/sheets/backward so external callers relying on the
|
||||
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
|
||||
// refactored ones. Command names are disjoint from sheets.Shortcuts().
|
||||
allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
|
||||
allShortcuts = append(allShortcuts, base.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, event.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
|
||||
@@ -146,6 +153,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
|
||||
if service == "mail" {
|
||||
mail.InstallOnMail(svc)
|
||||
}
|
||||
if service == "sheets" {
|
||||
applySheetsCompatGroups(svc)
|
||||
}
|
||||
|
||||
if !IsShortcutServiceAvailable(service, brand) {
|
||||
installBrandRestrictionGuard(svc, service, brand)
|
||||
@@ -189,3 +199,153 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
|
||||
// --help bypasses RunE, so surface the restriction in Long too.
|
||||
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
|
||||
}
|
||||
|
||||
// Sheets backward-compatibility help grouping.
|
||||
//
|
||||
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
|
||||
// users whose lark-sheets skill predates the refactor keep working even after
|
||||
// upgrading only the binary. In `sheets --help` those aliases would otherwise
|
||||
// sort alphabetically into the same flat list as the current commands,
|
||||
// indistinguishable from them. applySheetsCompatGroups splits them into a
|
||||
// dedicated cobra group whose heading tells the user to update their skill, and
|
||||
// appends a "(→ +new-command)" pointer to each alias so the migration target is
|
||||
// obvious. Pure presentation — the aliases stay fully executable.
|
||||
const (
|
||||
sheetsCurrentGroupID = "sheets-current"
|
||||
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
|
||||
// `sheets --help` grouping and the generic unknown-subcommand path
|
||||
// (cmd/root.go) classify these aliases the same way.
|
||||
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
|
||||
)
|
||||
|
||||
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
|
||||
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
|
||||
// absent from this map still land in the deprecated group, just without a
|
||||
// pointer, so a missing entry degrades gracefully rather than misgrouping.
|
||||
var sheetsAliasReplacement = map[string]string{
|
||||
// spreadsheet / sheet management
|
||||
"+create": "+workbook-create",
|
||||
"+info": "+workbook-info",
|
||||
"+export": "+workbook-export",
|
||||
"+create-sheet": "+sheet-create",
|
||||
"+copy-sheet": "+sheet-copy",
|
||||
"+delete-sheet": "+sheet-delete",
|
||||
"+update-sheet": "+sheet-rename / +sheet-move / …",
|
||||
// cell data
|
||||
"+read": "+cells-get",
|
||||
"+write": "+cells-set",
|
||||
"+append": "+cells-set",
|
||||
"+find": "+cells-search",
|
||||
"+replace": "+cells-replace",
|
||||
// cell style / merge / image
|
||||
"+set-style": "+cells-set-style",
|
||||
"+batch-set-style": "+cells-batch-set-style",
|
||||
"+merge-cells": "+cells-merge",
|
||||
"+unmerge-cells": "+cells-unmerge",
|
||||
"+write-image": "+cells-set-image",
|
||||
// row / column dimensions
|
||||
"+add-dimension": "+dim-insert",
|
||||
"+insert-dimension": "+dim-insert",
|
||||
"+update-dimension": "+rows-resize / +dim-hide / …",
|
||||
"+move-dimension": "+dim-move",
|
||||
"+delete-dimension": "+dim-delete",
|
||||
// filter views (conditions folded into the view flags)
|
||||
"+create-filter-view": "+filter-view-create",
|
||||
"+update-filter-view": "+filter-view-update",
|
||||
"+list-filter-views": "+filter-view-list",
|
||||
"+get-filter-view": "+filter-view-list",
|
||||
"+delete-filter-view": "+filter-view-delete",
|
||||
"+create-filter-view-condition": "+filter-view-update",
|
||||
"+update-filter-view-condition": "+filter-view-update",
|
||||
"+list-filter-view-conditions": "+filter-view-list",
|
||||
"+get-filter-view-condition": "+filter-view-list",
|
||||
"+delete-filter-view-condition": "+filter-view-update",
|
||||
// dropdowns
|
||||
"+set-dropdown": "+dropdown-set",
|
||||
"+update-dropdown": "+dropdown-update",
|
||||
"+get-dropdown": "+dropdown-get",
|
||||
"+delete-dropdown": "+dropdown-delete",
|
||||
// float images (media-upload folded into create)
|
||||
"+media-upload": "+float-image-create",
|
||||
"+create-float-image": "+float-image-create",
|
||||
"+update-float-image": "+float-image-update",
|
||||
"+get-float-image": "+float-image-list",
|
||||
"+list-float-images": "+float-image-list",
|
||||
"+delete-float-image": "+float-image-delete",
|
||||
}
|
||||
|
||||
func applySheetsCompatGroups(svc *cobra.Command) {
|
||||
svc.AddGroup(
|
||||
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
|
||||
&cobra.Group{
|
||||
ID: sheetsDeprecatedGroupID,
|
||||
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
|
||||
},
|
||||
)
|
||||
|
||||
deprecated := make(map[string]struct{})
|
||||
for _, s := range sheetsbackward.Shortcuts() {
|
||||
deprecated[s.Command] = struct{}{}
|
||||
}
|
||||
|
||||
for _, c := range svc.Commands() {
|
||||
name := c.Name()
|
||||
if _, ok := deprecated[name]; ok {
|
||||
c.GroupID = sheetsDeprecatedGroupID
|
||||
if repl := sheetsAliasReplacement[name]; repl != "" {
|
||||
c.Short = c.Short + " (→ " + repl + ")"
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
|
||||
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
|
||||
// auto-added help/completion ungrouped so cobra files them under
|
||||
// "Additional Commands".
|
||||
if len(name) > 0 && name[0] == '+' {
|
||||
c.GroupID = sheetsCurrentGroupID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
|
||||
// alias so that invoking it records a process-level deprecation notice, which
|
||||
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
|
||||
// the --help grouping cannot: those whose pre-refactor skill calls +read /
|
||||
// +write directly and never reads --help. Replacement targets come from
|
||||
// sheetsAliasReplacement — the same single source of truth that drives the
|
||||
// "(→ +new)" help pointers.
|
||||
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
|
||||
for i := range list {
|
||||
notice := &deprecation.Notice{
|
||||
Command: list[i].Command,
|
||||
Replacement: sheetsAliasReplacement[list[i].Command],
|
||||
Skill: "lark-sheets",
|
||||
}
|
||||
// Record the notice as soon as the command's own logic runs, so it is
|
||||
// surfaced even when Validate rejects the call — an out-of-date skill
|
||||
// can pass pre-refactor argument shapes (e.g. a range without the new
|
||||
// sheet-id prefix) and fail validation before Execute — and when
|
||||
// --dry-run short-circuits before Execute. Both hooks store the same
|
||||
// pointer, so setting it twice is harmless.
|
||||
if origValidate := list[i].Validate; origValidate != nil {
|
||||
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origValidate(ctx, runtime)
|
||||
}
|
||||
}
|
||||
if origExecute := list[i].Execute; origExecute != nil {
|
||||
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
deprecation.SetPending(notice)
|
||||
return origExecute(ctx, runtime)
|
||||
}
|
||||
}
|
||||
// The Validate/Execute wrappers above miss one path: a cobra-level
|
||||
// required flag (MarkFlagRequired) that is absent fails at
|
||||
// ValidateRequiredFlags, before RunE — so neither hook runs and the
|
||||
// notice would be lost on exactly the "stale skill calls the old command
|
||||
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
|
||||
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
|
||||
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package shortcuts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -16,7 +17,9 @@ import (
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -471,3 +474,152 @@ func TestGenerateShortcutsJSON(t *testing.T) {
|
||||
}
|
||||
t.Logf("wrote %d bytes to %s", len(data), output)
|
||||
}
|
||||
|
||||
// applySheetsCompatGroups must split the sheets service into a current group
|
||||
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
|
||||
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
|
||||
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
|
||||
// under "Additional Commands".
|
||||
func TestApplySheetsCompatGroups(t *testing.T) {
|
||||
svc := &cobra.Command{Use: "sheets"}
|
||||
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
|
||||
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
|
||||
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
|
||||
svc.AddCommand(newCmd, aliasCmd, metaCmd)
|
||||
|
||||
applySheetsCompatGroups(svc)
|
||||
|
||||
if !svc.ContainsGroup(sheetsCurrentGroupID) {
|
||||
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
|
||||
}
|
||||
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
|
||||
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
|
||||
}
|
||||
if newCmd.GroupID != sheetsCurrentGroupID {
|
||||
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
|
||||
}
|
||||
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
|
||||
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
|
||||
}
|
||||
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
|
||||
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
|
||||
}
|
||||
if metaCmd.GroupID != "" {
|
||||
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
|
||||
// heading (telling users to update their skill) plus the per-alias migration
|
||||
// pointers, while keeping the refactored shortcuts under Available Commands.
|
||||
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
sheetsCmd, _, err := program.Find([]string{"sheets"})
|
||||
if err != nil {
|
||||
t.Fatalf("find sheets command: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
sheetsCmd.SetOut(&out)
|
||||
if err := sheetsCmd.Help(); err != nil {
|
||||
t.Fatalf("sheets help failed: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Available Commands:",
|
||||
"Deprecated pre-refactor commands",
|
||||
"update your lark-sheets skill",
|
||||
"+read",
|
||||
"(→ +cells-get)",
|
||||
"+write",
|
||||
"(→ +cells-set)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("sheets help missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
|
||||
// invoking it records a process-level deprecation notice (reusing
|
||||
// sheetsAliasReplacement for the migration target) while still calling the
|
||||
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
|
||||
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
called := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+read",
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("wrapped list len = %d, want 1", len(out))
|
||||
}
|
||||
if deprecation.GetPending() != nil {
|
||||
t.Fatal("notice set before wrapped Execute ran")
|
||||
}
|
||||
|
||||
if err := out[0].Execute(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Execute returned error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("original Execute was not invoked by the wrapper")
|
||||
}
|
||||
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil {
|
||||
t.Fatal("expected a pending deprecation notice after Execute")
|
||||
}
|
||||
if dep.Command != "+read" {
|
||||
t.Errorf("notice Command = %q, want +read", dep.Command)
|
||||
}
|
||||
if dep.Replacement != "+cells-get" {
|
||||
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
|
||||
}
|
||||
if dep.Skill != "lark-sheets" {
|
||||
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper must also decorate Validate, so an out-of-date skill whose
|
||||
// pre-refactor argument shape fails validation (before Execute) still gets the
|
||||
// deprecation notice in its error envelope.
|
||||
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
|
||||
t.Cleanup(func() { deprecation.SetPending(nil) })
|
||||
deprecation.SetPending(nil)
|
||||
|
||||
validated := false
|
||||
in := []common.Shortcut{{
|
||||
Service: "sheets",
|
||||
Command: "+write",
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
validated = true
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
|
||||
out := wrapSheetsBackwardDeprecation(in)
|
||||
if out[0].Validate == nil {
|
||||
t.Fatal("Validate hook was dropped by the wrapper")
|
||||
}
|
||||
if err := out[0].Validate(context.Background(), nil); err != nil {
|
||||
t.Fatalf("wrapped Validate returned error: %v", err)
|
||||
}
|
||||
if !validated {
|
||||
t.Fatal("original Validate was not invoked")
|
||||
}
|
||||
dep := deprecation.GetPending()
|
||||
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
|
||||
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
|
||||
}
|
||||
}
|
||||
|
||||
239
shortcuts/sheets/backward/helpers.go
Normal file
239
shortcuts/sheets/backward/helpers.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package backward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
|
||||
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
|
||||
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
|
||||
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
|
||||
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
|
||||
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
|
||||
)
|
||||
|
||||
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!")
|
||||
|
||||
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
|
||||
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) > 0 {
|
||||
sheet, _ := sheets[0].(map[string]interface{})
|
||||
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
|
||||
}
|
||||
|
||||
// extractSpreadsheetToken extracts spreadsheet token from URL.
|
||||
func extractSpreadsheetToken(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
prefixes := []string{"/sheets/", "/spreadsheets/"}
|
||||
for _, prefix := range prefixes {
|
||||
if idx := strings.Index(input, prefix); idx >= 0 {
|
||||
token := input[idx+len(prefix):]
|
||||
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
|
||||
token = token[:idx2]
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeSheetRange(sheetID, input string) string {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID == "" {
|
||||
return input
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return sheetID + "!" + input
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizePointRange(sheetID, input string) string {
|
||||
input = normalizeSheetRange(sheetID, input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
rangeSheetID, subRange, ok := splitSheetRange(input)
|
||||
if !ok || !singleCellRangePattern.MatchString(subRange) {
|
||||
return input
|
||||
}
|
||||
return rangeSheetID + "!" + subRange + ":" + subRange
|
||||
}
|
||||
|
||||
func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
rows, cols := matrixDimensions(values)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return buildRectRange(sheetID, "A1", rows, cols)
|
||||
}
|
||||
|
||||
input = normalizeSheetRange(sheetID, input)
|
||||
rangeSheetID, subRange, ok := splitSheetRange(input)
|
||||
if !ok {
|
||||
return buildRectRange(input, "A1", rows, cols)
|
||||
}
|
||||
if singleCellRangePattern.MatchString(subRange) {
|
||||
return buildRectRange(rangeSheetID, subRange, rows, cols)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func validateSheetRangeInput(sheetID, input string) error {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID != "" {
|
||||
return nil
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
|
||||
// invalid for single-cell operations like write-image. Empty and single-cell
|
||||
// values pass through.
|
||||
func validateSingleCellRange(input string) error {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
// Extract the sub-range after the sheet ID prefix, if present.
|
||||
subRange := input
|
||||
if _, sr, ok := splitSheetRange(input); ok {
|
||||
subRange = sr
|
||||
}
|
||||
if cellSpanRangePattern.MatchString(subRange) {
|
||||
parts := strings.SplitN(subRange, ":", 2)
|
||||
if strings.EqualFold(parts[0], parts[1]) {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeRelativeRange(input string) bool {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return false
|
||||
}
|
||||
return singleCellRangePattern.MatchString(input) ||
|
||||
cellSpanRangePattern.MatchString(input) ||
|
||||
cellToColRangePattern.MatchString(input) ||
|
||||
colSpanRangePattern.MatchString(input) ||
|
||||
rowSpanRangePattern.MatchString(input)
|
||||
}
|
||||
|
||||
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
|
||||
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func normalizeSheetRangeSeparators(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
return sheetRangeSeparatorReplacer.Replace(input)
|
||||
}
|
||||
|
||||
func buildRectRange(sheetID, anchor string, rows, cols int) string {
|
||||
if sheetID == "" {
|
||||
return ""
|
||||
}
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
if cols < 1 {
|
||||
cols = 1
|
||||
}
|
||||
endCell, err := offsetCell(anchor, rows-1, cols-1)
|
||||
if err != nil {
|
||||
return sheetID
|
||||
}
|
||||
return sheetID + "!" + anchor + ":" + endCell
|
||||
}
|
||||
|
||||
func matrixDimensions(values interface{}) (rows, cols int) {
|
||||
rowList, ok := values.([]interface{})
|
||||
if !ok || len(rowList) == 0 {
|
||||
return 1, 1
|
||||
}
|
||||
rows = len(rowList)
|
||||
for _, row := range rowList {
|
||||
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
|
||||
cols = len(cells)
|
||||
}
|
||||
}
|
||||
if cols == 0 {
|
||||
cols = 1
|
||||
}
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
|
||||
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
|
||||
if len(matches) != 3 {
|
||||
return "", fmt.Errorf("invalid cell reference: %s", cell)
|
||||
}
|
||||
colIndex := columnNameToIndex(matches[1])
|
||||
if colIndex < 1 {
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1])
|
||||
}
|
||||
rowIndex, err := strconv.Atoi(matches[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
|
||||
}
|
||||
|
||||
func columnNameToIndex(name string) int {
|
||||
name = strings.ToUpper(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return 0
|
||||
}
|
||||
index := 0
|
||||
for _, r := range name {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return 0
|
||||
}
|
||||
index = index*26 + int(r-'A'+1)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func columnIndexToName(index int) string {
|
||||
if index < 1 {
|
||||
return ""
|
||||
}
|
||||
var out []byte
|
||||
for index > 0 {
|
||||
index--
|
||||
out = append([]byte{byte('A' + index%26)}, out...)
|
||||
index /= 26
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -54,10 +54,14 @@ var SheetRead = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
readRange := runtime.Str("range")
|
||||
if readRange == "" && runtime.Str("sheet-id") != "" {
|
||||
if readRange == "" {
|
||||
// Sheet-only selector: pass the bare sheet id through verbatim.
|
||||
// Routing it via the range normalizer mangles ids that look
|
||||
// A1-ish (e.g. "shtABC123" -> "shtABC123!shtABC123:shtABC123").
|
||||
readRange = runtime.Str("sheet-id")
|
||||
} else {
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
}
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/sheets/v2/spreadsheets/:token/values/:range").
|
||||
Set("token", token).Set("range", readRange)
|
||||
@@ -66,18 +70,19 @@ var SheetRead = common.Shortcut{
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
|
||||
readRange := runtime.Str("range")
|
||||
if readRange == "" && runtime.Str("sheet-id") != "" {
|
||||
readRange = runtime.Str("sheet-id")
|
||||
}
|
||||
|
||||
if readRange == "" {
|
||||
var err error
|
||||
readRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
// Sheet-only selector: keep the resolved sheet id verbatim (see DryRun).
|
||||
readRange = runtime.Str("sheet-id")
|
||||
if readRange == "" {
|
||||
var err error
|
||||
readRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
}
|
||||
readRange = normalizePointRange(runtime.Str("sheet-id"), readRange)
|
||||
|
||||
params := map[string]interface{}{}
|
||||
renderOption := runtime.Str("value-render-option")
|
||||
@@ -124,11 +129,14 @@ var SheetWrite = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
writeRange := runtime.Str("range")
|
||||
if writeRange == "" && runtime.Str("sheet-id") != "" {
|
||||
writeRange = runtime.Str("sheet-id")
|
||||
}
|
||||
values, _ := parseValues2DJSON(runtime.Str("values"))
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
if writeRange == "" {
|
||||
// Sheet-only selector: build the write rect from the selector's
|
||||
// A1 instead of treating the bare sheet id as a cell anchor.
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), "", values)
|
||||
} else {
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/sheets/v2/spreadsheets/:token/values").
|
||||
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": writeRange, "values": values}}).
|
||||
@@ -143,18 +151,21 @@ var SheetWrite = common.Shortcut{
|
||||
}
|
||||
|
||||
writeRange := runtime.Str("range")
|
||||
if writeRange == "" && runtime.Str("sheet-id") != "" {
|
||||
writeRange = runtime.Str("sheet-id")
|
||||
}
|
||||
|
||||
if writeRange == "" {
|
||||
var err error
|
||||
writeRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
// Sheet-only selector: build the write rect from the selector's
|
||||
// A1 (see DryRun). Resolve the first sheet when none was given.
|
||||
sel := runtime.Str("sheet-id")
|
||||
if sel == "" {
|
||||
var err error
|
||||
sel, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writeRange = normalizeWriteRange(sel, "", values)
|
||||
} else {
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
}
|
||||
writeRange = normalizeWriteRange(runtime.Str("sheet-id"), writeRange, values)
|
||||
|
||||
data, err := runtime.CallAPI("PUT", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
@@ -200,11 +211,14 @@ var SheetAppend = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := validateSheetManageToken(runtime)
|
||||
appendRange := runtime.Str("range")
|
||||
if appendRange == "" && runtime.Str("sheet-id") != "" {
|
||||
if appendRange == "" {
|
||||
// Sheet-only selector: pass the bare sheet id through verbatim
|
||||
// (see SheetRead.DryRun for the normalizer-mangling rationale).
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
} else {
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
}
|
||||
values, _ := parseValues2DJSON(runtime.Str("values"))
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/sheets/v2/spreadsheets/:token/values_append").
|
||||
Body(map[string]interface{}{"valueRange": map[string]interface{}{"range": appendRange, "values": values}}).
|
||||
@@ -219,18 +233,19 @@ var SheetAppend = common.Shortcut{
|
||||
}
|
||||
|
||||
appendRange := runtime.Str("range")
|
||||
if appendRange == "" && runtime.Str("sheet-id") != "" {
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
}
|
||||
|
||||
if appendRange == "" {
|
||||
var err error
|
||||
appendRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
// Sheet-only selector: keep the resolved sheet id verbatim (see DryRun).
|
||||
appendRange = runtime.Str("sheet-id")
|
||||
if appendRange == "" {
|
||||
var err error
|
||||
appendRange, err = getFirstSheetID(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
}
|
||||
appendRange = normalizePointRange(runtime.Str("sheet-id"), appendRange)
|
||||
|
||||
data, err := runtime.CallAPI("POST", fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/values_append", validate.EncodePathSegment(token)), nil, map[string]interface{}{
|
||||
"valueRange": map[string]interface{}{
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -355,6 +355,7 @@ func wrapCopySheetMoveError(err error, token, sheetID string, index int) error {
|
||||
Detail: mergeSheetErrorDetail(exitErr.Detail.Detail, detail),
|
||||
},
|
||||
Err: err,
|
||||
Raw: exitErr.Raw,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -126,6 +126,60 @@ func TestSheetAppendDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A bare sheet selector (no --range) must pass through verbatim. Sheet ids
|
||||
// that look A1-ish (letters+digits) would otherwise be mangled by the range
|
||||
// normalizer into "<id>!<id>:<id>".
|
||||
|
||||
func TestSheetReadDryRunSheetOnlyVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": "",
|
||||
"sheet-id": "shtABC123",
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetRead.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"shtABC123"`) {
|
||||
t.Fatalf("SheetRead.DryRun() = %s, want bare sheet id verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetWriteDryRunSheetOnlyBuildsRect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": "",
|
||||
"sheet-id": "shtABC123",
|
||||
"values": `[["x"]]`,
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetWrite.DryRun(context.Background(), runtime))
|
||||
// Built from the sheet's A1 (A1:A1 for a 1x1 write), NOT the mangled
|
||||
// "shtABC123!shtABC123:shtABC123" that piping a bare id through the
|
||||
// range normalizer produced.
|
||||
if !strings.Contains(got, `"range":"shtABC123!A1:A1"`) {
|
||||
t.Fatalf("SheetWrite.DryRun() = %s, want rect built from sheet A1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetAppendDryRunSheetOnlyVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newSheetsTestRuntime(t, map[string]string{
|
||||
"spreadsheet-token": "sht_test",
|
||||
"range": "",
|
||||
"sheet-id": "shtABC123",
|
||||
"values": `[["foo"]]`,
|
||||
}, nil)
|
||||
|
||||
got := mustMarshalSheetsDryRun(t, SheetAppend.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"range":"shtABC123"`) {
|
||||
t.Fatalf("SheetAppend.DryRun() = %s, want bare sheet id verbatim", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetFindDryRunNormalizesEscapedSeparator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
package backward
|
||||
|
||||
import (
|
||||
"context"
|
||||
71
shortcuts/sheets/backward/shortcuts.go
Normal file
71
shortcuts/sheets/backward/shortcuts.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package backward
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all sheets shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
// Spreadsheet management
|
||||
SheetCreate,
|
||||
SheetInfo,
|
||||
SheetExport,
|
||||
|
||||
// Sheet management
|
||||
SheetCreateSheet,
|
||||
SheetCopySheet,
|
||||
SheetDeleteSheet,
|
||||
SheetUpdateSheet,
|
||||
|
||||
// Cell data
|
||||
SheetRead,
|
||||
SheetWrite,
|
||||
SheetAppend,
|
||||
SheetFind,
|
||||
SheetReplace,
|
||||
|
||||
// Cell style and merge
|
||||
SheetSetStyle,
|
||||
SheetBatchSetStyle,
|
||||
SheetMergeCells,
|
||||
SheetUnmergeCells,
|
||||
|
||||
// Cell images
|
||||
SheetWriteImage,
|
||||
|
||||
// Row/column management
|
||||
SheetAddDimension,
|
||||
SheetInsertDimension,
|
||||
SheetUpdateDimension,
|
||||
SheetMoveDimension,
|
||||
SheetDeleteDimension,
|
||||
|
||||
// Filter views
|
||||
SheetCreateFilterView,
|
||||
SheetUpdateFilterView,
|
||||
SheetListFilterViews,
|
||||
SheetGetFilterView,
|
||||
SheetDeleteFilterView,
|
||||
SheetCreateFilterViewCondition,
|
||||
SheetUpdateFilterViewCondition,
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
|
||||
// Dropdown
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
|
||||
// Float images
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
}
|
||||
}
|
||||
907
shortcuts/sheets/batch_op_contract_test.go
Normal file
907
shortcuts/sheets/batch_op_contract_test.go
Normal file
@@ -0,0 +1,907 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable
|
||||
// shortcut, the MCP body produced inside +batch-update must be byte-for-byte
|
||||
// identical to the body the same shortcut produces when invoked standalone
|
||||
// (both observed via --dry-run, comparing tool_name + decoded input). This is
|
||||
// what guarantees "a sub-op behaves exactly like the standalone command", and
|
||||
// it is the regression guard for the whole flag→body translator reuse.
|
||||
//
|
||||
// Each case provides the standalone CLI args and the equivalent sub-op input
|
||||
// object (same CLI flag names, minus the spreadsheet locator which the batch
|
||||
// supplies at the top level).
|
||||
func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
shortcut string
|
||||
sc common.Shortcut
|
||||
// standalone args (excluding --url, which every case shares)
|
||||
args []string
|
||||
// sub-op input object as JSON (CLI flag names; no excel_id/url)
|
||||
subInput string
|
||||
}{
|
||||
{
|
||||
shortcut: "+cells-set",
|
||||
sc: CellsSet,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-clear",
|
||||
sc: CellsClear,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-replace",
|
||||
sc: CellsReplace,
|
||||
args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"},
|
||||
subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+csv-put",
|
||||
sc: CsvPut,
|
||||
args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"},
|
||||
subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-merge",
|
||||
sc: CellsMerge,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cells-unmerge",
|
||||
sc: CellsUnmerge,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:C1"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:C1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-insert",
|
||||
sc: DimInsert,
|
||||
args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"},
|
||||
subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-delete",
|
||||
sc: DimDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "C:D"},
|
||||
subInput: `{"sheet-id":"sh1","range":"C:D"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-hide",
|
||||
sc: DimHide,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "2:3"},
|
||||
subInput: `{"sheet-id":"sh1","range":"2:3"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-freeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
|
||||
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dim-group",
|
||||
sc: DimGroup,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"},
|
||||
subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+rows-resize",
|
||||
sc: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
|
||||
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cols-resize",
|
||||
sc: ColsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"},
|
||||
subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-move",
|
||||
sc: RangeMove,
|
||||
args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"},
|
||||
subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-copy",
|
||||
sc: RangeCopy,
|
||||
args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"},
|
||||
subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-fill",
|
||||
sc: RangeFill,
|
||||
args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"},
|
||||
subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+range-sort",
|
||||
sc: RangeSort,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-create",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--title", "New", "--index", "2"},
|
||||
subInput: `{"title":"New","index":2}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-delete",
|
||||
sc: SheetDelete,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-rename",
|
||||
sc: SheetRename,
|
||||
args: []string{"--sheet-id", "sh1", "--title", "Renamed"},
|
||||
subInput: `{"sheet-id":"sh1","title":"Renamed"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-copy",
|
||||
sc: SheetCopy,
|
||||
args: []string{"--sheet-id", "sh1", "--title", "Copy"},
|
||||
subInput: `{"sheet-id":"sh1","title":"Copy"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide",
|
||||
sc: SheetHide,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-unhide",
|
||||
sc: SheetUnhide,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-set-tab-color",
|
||||
sc: SheetSetTabColor,
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`,
|
||||
},
|
||||
{
|
||||
// --highlight=false explicitly opts out of the server's new
|
||||
// enable_highlight=true default. Covers the tri-state Changed()
|
||||
// branch in buildDropdownValidation: standalone reads the cobra
|
||||
// "Changed" bit; sub-op reads the key's presence in the map.
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"},
|
||||
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-create",
|
||||
sc: ChartCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
||||
subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-update",
|
||||
sc: ChartUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+chart-delete",
|
||||
sc: ChartDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
|
||||
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+pivot-create",
|
||||
sc: PivotCreate,
|
||||
// +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id /
|
||||
// --target-sheet-name to flag the placement-sheet semantics (the data
|
||||
// source is in --source). Both standalone args and the +batch-update
|
||||
// sub-op input must use the new names.
|
||||
args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"},
|
||||
subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+cond-format-create",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`},
|
||||
subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-create",
|
||||
sc: FilterCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-update",
|
||||
sc: FilterUpdate,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-delete",
|
||||
sc: FilterDelete,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+filter-view-create",
|
||||
sc: FilterViewCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`},
|
||||
subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sparkline-create",
|
||||
sc: SparklineCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`},
|
||||
subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sparkline-delete",
|
||||
sc: SparklineDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--group-id", "g1"},
|
||||
subInput: `{"sheet-id":"sh1","group-id":"g1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+float-image-create",
|
||||
sc: FloatImageCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
|
||||
subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+float-image-delete",
|
||||
sc: FloatImageDelete,
|
||||
args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"},
|
||||
subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mapping, ok := batchOpDispatch[tc.shortcut]
|
||||
if !ok {
|
||||
t.Fatalf("%s not in batchOpDispatch", tc.shortcut)
|
||||
}
|
||||
|
||||
// Standalone body via the shortcut's own dry-run.
|
||||
standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName)
|
||||
|
||||
// Batch body via the +batch-update translator.
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
fv := newMapFlagViewForCommand(tc.shortcut, subInput)
|
||||
// Match what translateBatchOp does — read the sheet selector
|
||||
// via the shortcut-specific flag names so +pivot-create
|
||||
// (target-sheet-id / target-sheet-name) and the rest
|
||||
// (sheet-id / sheet-name) both resolve correctly.
|
||||
sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut)
|
||||
sidStr, _ := subInput[sidFlag].(string)
|
||||
snameStr, _ := subInput[snameFlag].(string)
|
||||
batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr)
|
||||
if err != nil {
|
||||
t.Fatalf("batch translate failed: %v", err)
|
||||
}
|
||||
|
||||
// Round-trip the batch body through JSON so number types match the
|
||||
// standalone path (which is decoded from a JSON string).
|
||||
batchBody = jsonRoundTrip(t, batchBody)
|
||||
|
||||
if !reflect.DeepEqual(standaloneBody, batchBody) {
|
||||
t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestBatchOp_ErrorEquivalence is the second half of the contract: for the
|
||||
// same bad input, the standalone shortcut Validate and the +batch-update
|
||||
// sub-op translator must emit the same friendly CLI error. Previously a
|
||||
// sub-op that omitted --sheet-id (or another required flag) slipped through
|
||||
// to the server and surfaced as "sheet undefined not found"; with the
|
||||
// validation pushed down into the xxxInput builders both paths now stop the
|
||||
// request before the API call.
|
||||
//
|
||||
// Scope: this test covers checks that cobra cannot enforce — XOR pairs
|
||||
// (sheet selector, image token/uri), range relationships, enum-bound rules,
|
||||
// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the
|
||||
// single-required cases on the standalone path with its own
|
||||
// "required flag(s) \"X\" not set" wording; the batch path now catches the
|
||||
// same situations with our friendlier "--X is required" wording — those are
|
||||
// asserted by TestBatchOp_RejectsBadSubOpInput below.
|
||||
func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
// shortcut & standalone args. --url is supplied by the runner. Args
|
||||
// satisfy every cobra-required flag so cobra doesn't short-circuit
|
||||
// before our shared validator runs.
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
// matching sub-op input; reach the same failing check.
|
||||
subShortcut string
|
||||
subInput string
|
||||
// substring expected in both errors. We assert *contains* rather than
|
||||
// equality because the batch path wraps the inner error with
|
||||
// "operations[i] (<name>): " context — the inner message must match.
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "+cells-set missing sheet selector",
|
||||
shortcut: CellsSet,
|
||||
args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`},
|
||||
subShortcut: "+cells-set",
|
||||
subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+cells-set both sheet-id and sheet-name",
|
||||
shortcut: CellsSet,
|
||||
args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`},
|
||||
subShortcut: "+cells-set",
|
||||
subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`,
|
||||
wantContains: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "+dim-insert missing sheet selector",
|
||||
shortcut: DimInsert,
|
||||
args: []string{"--position", "1", "--count", "1"},
|
||||
subShortcut: "+dim-insert",
|
||||
subInput: `{"position":"1","count":1}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+dim-insert count <= 0",
|
||||
shortcut: DimInsert,
|
||||
args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"},
|
||||
subShortcut: "+dim-insert",
|
||||
subInput: `{"sheet-id":"sh1","position":"5","count":0}`,
|
||||
wantContains: "--count must be > 0",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
shortcut: RowsResize,
|
||||
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
|
||||
subShortcut: "+rows-resize",
|
||||
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
|
||||
wantContains: "--type pixel requires --size",
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete missing sheet selector",
|
||||
shortcut: SheetDelete,
|
||||
args: []string{},
|
||||
subShortcut: "+sheet-delete",
|
||||
subInput: `{}`,
|
||||
wantContains: "specify at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+float-image-create both image-token and image-uri",
|
||||
shortcut: FloatImageCreate,
|
||||
args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
|
||||
subShortcut: "+float-image-create",
|
||||
subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
wantContains: "mutually exclusive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Standalone path: run the shortcut with --dry-run + bad args.
|
||||
// Validate runs before DryRun, so we expect it to fail there.
|
||||
_, _, standaloneErr := runShortcutCapturingErr(
|
||||
t, tc.shortcut,
|
||||
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
|
||||
)
|
||||
if standaloneErr == nil {
|
||||
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
|
||||
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
|
||||
}
|
||||
|
||||
// Batch path: translate the matching sub-op. The translator wraps
|
||||
// the inner error with "operations[i] (<shortcut>): " — assert the
|
||||
// inner message survives the wrap.
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{
|
||||
"shortcut": tc.subShortcut,
|
||||
"input": subInput,
|
||||
}
|
||||
_, batchErr := translateBatchOp(rawOp, testToken, 0)
|
||||
if batchErr == nil {
|
||||
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(batchErr.Error(), tc.wantContains) {
|
||||
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
|
||||
}
|
||||
// And the wrap context must include the sub-op index + shortcut
|
||||
// name so error reports stay actionable in multi-op batches.
|
||||
wrapHint := "operations[0] (" + tc.subShortcut + "):"
|
||||
if !strings.Contains(batchErr.Error(), wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
|
||||
// silent-coercion gap: `operations` skips parse-time schema validation, and
|
||||
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
|
||||
// whose JSON type contradicts its flag-defs type must be rejected up front
|
||||
// rather than landing as 0 / false in the wrong place.
|
||||
func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "int flag given a string",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`,
|
||||
wantContains: "--index must be a number",
|
||||
},
|
||||
{
|
||||
name: "int flag given a boolean",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`,
|
||||
wantContains: "--source-index must be a number",
|
||||
},
|
||||
{
|
||||
// Standalone cobra rejects 1.5 for an int flag at parse time;
|
||||
// mapFlagView.Int would silently truncate it to 1, so the batch
|
||||
// path must reject it too instead of executing on a floored index.
|
||||
name: "int flag given a non-integer number",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`,
|
||||
wantContains: "--index must be an integer",
|
||||
},
|
||||
{
|
||||
name: "bool flag given a string",
|
||||
subShortcut: "+cells-set",
|
||||
subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`,
|
||||
wantContains: "--allow-overwrite must be a boolean",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone
|
||||
// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is
|
||||
// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor:
|
||||
// - +csv-put: standalone requires one-of(start-cell, range) via cobra's
|
||||
// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra.
|
||||
// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in
|
||||
// SheetMove.Validate; the batch path uses a dedicated builder.
|
||||
//
|
||||
// Without an explicit guard, mapFlagView's flag-default fallback silently wins
|
||||
// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone
|
||||
// contract instead of failing.
|
||||
func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "+csv-put without start-cell or range",
|
||||
subShortcut: "+csv-put",
|
||||
subInput: `{"sheet-id":"sh1","csv":"a,b"}`,
|
||||
wantContains: "--start-cell or --range is required",
|
||||
},
|
||||
{
|
||||
name: "+sheet-move without index",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2}`,
|
||||
wantContains: "requires index",
|
||||
},
|
||||
{
|
||||
name: "+sheet-move negative index",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`,
|
||||
wantContains: "--index must be >= 0",
|
||||
},
|
||||
{
|
||||
name: "+sheet-move negative source-index",
|
||||
subShortcut: "+sheet-move",
|
||||
subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`,
|
||||
wantContains: "--source-index must be >= 0",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for
|
||||
// inputs that cobra's MarkFlagRequired catches on the standalone path,
|
||||
// the +batch-update sub-op (which has no cobra layer) must still reject
|
||||
// CLI-side with its own friendly error before issuing any API call. This
|
||||
// closes the original bug — a sub-op missing --sheet-id used to slip
|
||||
// through and surface as "sheet undefined not found" only after a
|
||||
// network round-trip.
|
||||
func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
"+cells-set missing --range",
|
||||
"+cells-set",
|
||||
`{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`,
|
||||
"--range is required",
|
||||
},
|
||||
{
|
||||
"+dim-insert missing --position",
|
||||
"+dim-insert",
|
||||
`{"sheet-id":"sh1","count":1}`,
|
||||
"--position is required",
|
||||
},
|
||||
{
|
||||
"+rows-resize missing --type",
|
||||
"+rows-resize",
|
||||
`{"sheet-id":"sh1","range":"1:1"}`,
|
||||
"--type is required",
|
||||
},
|
||||
{
|
||||
"+range-copy missing --target-range",
|
||||
"+range-copy",
|
||||
`{"sheet-id":"sh1","source-range":"A1:B2"}`,
|
||||
"--target-range is required",
|
||||
},
|
||||
{
|
||||
"+sheet-rename missing --title",
|
||||
"+sheet-rename",
|
||||
`{"sheet-id":"sh1"}`,
|
||||
"--title is required",
|
||||
},
|
||||
{
|
||||
"+chart-update missing --chart-id",
|
||||
"+chart-update",
|
||||
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
|
||||
"--chart-id is required",
|
||||
},
|
||||
{
|
||||
"+filter-create missing --range",
|
||||
"+filter-create",
|
||||
`{"sheet-id":"sh1"}`,
|
||||
"--range is required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing --float-image-id",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
"--float-image-id is required",
|
||||
},
|
||||
// +float-image-update's core (image_name / position / size) is mandatory
|
||||
// on update too — the tool rejects without them and +float-image-list
|
||||
// can't backfill image_name. cobra gates these on the standalone path;
|
||||
// the batch sub-op must reject them here. The image source stays optional
|
||||
// (omitting it keeps the current image), so these inputs omit it.
|
||||
{
|
||||
"+float-image-update missing --image-name",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
|
||||
"--image-name is required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing position",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`,
|
||||
"--position-row and --position-col are required",
|
||||
},
|
||||
{
|
||||
"+float-image-update missing size",
|
||||
"+float-image-update",
|
||||
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`,
|
||||
"--size-width and --size-height are required",
|
||||
},
|
||||
// +filter-{update,delete} need sheet-id (not sheet-name) because
|
||||
// server contract: filter_id === sheet_id, and we can't resolve
|
||||
// sheet-name → sheet-id mid-batch.
|
||||
{
|
||||
"+filter-update with --sheet-name only (filter_id must equal sheet_id)",
|
||||
"+filter-update",
|
||||
`{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`,
|
||||
"+filter-update requires --sheet-id",
|
||||
},
|
||||
{
|
||||
"+filter-delete with --sheet-name only (filter_id must equal sheet_id)",
|
||||
"+filter-delete",
|
||||
`{"sheet-name":"Sheet1"}`,
|
||||
"+filter-delete requires --sheet-id",
|
||||
},
|
||||
// +sparkline-update requires sparkline_id on every
|
||||
// properties.sparklines[i] (server contract). CLI surfaces this
|
||||
// with a pointer to +sparkline-list so the agent doesn't have to
|
||||
// guess the id from an opaque server-side rejection.
|
||||
{
|
||||
"+sparkline-update item missing sparkline_id",
|
||||
"+sparkline-update",
|
||||
`{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`,
|
||||
"missing sparkline_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{
|
||||
"shortcut": tc.subShortcut,
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven
|
||||
// validator fires on +batch-update sub-operations the same way it
|
||||
// fires on standalone shortcuts. mapFlagView.Command() returns the
|
||||
// sub-op's shortcut name, so validateInputAgainstSchema (called at
|
||||
// each input builder's tail) routes through the same (command, flag)
|
||||
// lookup pipeline a standalone invocation would. This regression
|
||||
// pins that wiring — without it, agents could slip past CLI-side
|
||||
// schema checks by wrapping a bad input in +batch-update.
|
||||
func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subShortcut string
|
||||
subInput string
|
||||
wantContains string
|
||||
}{
|
||||
// +pivot-create properties.values items enforce summarize_by
|
||||
// enum — schema rejects an out-of-enum value as a sub-op too.
|
||||
{
|
||||
"+pivot-create summarize_by out of enum",
|
||||
"+pivot-create",
|
||||
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
|
||||
"summarize_by",
|
||||
},
|
||||
// +chart-create properties.position.row has minimum:0 — P0
|
||||
// addition; validator must catch -1 even in the batch path.
|
||||
{
|
||||
"+chart-create position.row below minimum",
|
||||
"+chart-create",
|
||||
`{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`,
|
||||
"below minimum",
|
||||
},
|
||||
// +cells-set --cells is a 2D array of objects per the
|
||||
// upstream-fixed schema; sub-op passing an object must be
|
||||
// rejected at the schema layer (not "expected JSON array").
|
||||
{
|
||||
"+cells-set cells wrong shape",
|
||||
"+cells-set",
|
||||
`{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`,
|
||||
`expected type "array"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var subInput map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
|
||||
t.Fatalf("bad subInput JSON: %v", err)
|
||||
}
|
||||
rawOp := map[string]interface{}{
|
||||
"shortcut": tc.subShortcut,
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two
|
||||
// originally reported failures: +range-copy and +rows-resize sub-ops must
|
||||
// translate to the correct MCP body (not a near-passthrough that drops
|
||||
// required fields).
|
||||
func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// +range-copy → transform_range with range / destination_range (not the
|
||||
// raw source_range / target_range that used to leak through).
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" {
|
||||
t.Errorf("+range-copy sub-op body wrong: %#v", copyIn)
|
||||
}
|
||||
if copyIn["operation"] != "copy" {
|
||||
t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"])
|
||||
}
|
||||
|
||||
// +rows-resize → resize_range with range + resize_height. The CLI's single
|
||||
// "23" input must be expanded to "23:23" because resize_range rejects
|
||||
// bare single-element ranges.
|
||||
body = parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
|
||||
"--yes",
|
||||
})
|
||||
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
|
||||
resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if resizeIn["range"] != "23:23" {
|
||||
t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"])
|
||||
}
|
||||
rh, _ := resizeIn["resize_height"].(map[string]interface{})
|
||||
if rh == nil || rh["type"] != "pixel" {
|
||||
t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity
|
||||
// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies
|
||||
// the sheet locator but omits all of the shortcut's business-required flags must
|
||||
// fail in translateBatchOp — never silently fall back to a default. The earlier
|
||||
// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked
|
||||
// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it
|
||||
// guards the whole surface and auto-covers any shortcut added later. If a future
|
||||
// refactor moves a required check out of the shared *Input builder (the exact
|
||||
// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op
|
||||
// would start accepting missing args and this test fails.
|
||||
func TestBatchOp_RequiredFlagParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs: %v", err)
|
||||
}
|
||||
// Flags supplied by the +batch-update top level (url/token), or that form the
|
||||
// sub-op's own sheet selector, are context — not "business" inputs.
|
||||
locator := map[string]bool{
|
||||
"url": true, "spreadsheet-token": true,
|
||||
"sheet-id": true, "sheet-name": true,
|
||||
"target-sheet-id": true, "target-sheet-name": true,
|
||||
}
|
||||
// How each command expresses its sheet locator in a sub-op, so the error we
|
||||
// trigger is the business one, not a missing-locator error.
|
||||
sheetSel := func(cmd string) map[string]interface{} {
|
||||
switch cmd {
|
||||
case "+sheet-create": // create needs no existing-sheet anchor
|
||||
return map[string]interface{}{}
|
||||
case "+pivot-create": // placement selector is target-sheet-*; data source is --source
|
||||
return map[string]interface{}{"target-sheet-id": "sh1"}
|
||||
default:
|
||||
return map[string]interface{}{"sheet-id": "sh1"}
|
||||
}
|
||||
}
|
||||
for cmd := range batchOpDispatch {
|
||||
spec, ok := defs[cmd]
|
||||
if !ok {
|
||||
t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd)
|
||||
continue
|
||||
}
|
||||
var business []string
|
||||
for _, fl := range spec.Flags {
|
||||
if fl.Kind == "system" || locator[fl.Name] {
|
||||
continue
|
||||
}
|
||||
if fl.Required == "required" || fl.Required == "xor" {
|
||||
business = append(business, fl.Name)
|
||||
}
|
||||
}
|
||||
if len(business) == 0 {
|
||||
continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit
|
||||
}
|
||||
t.Run(cmd, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+
|
||||
"(batch must reject missing required flags, not silently default)", cmd, business)
|
||||
return
|
||||
}
|
||||
// The sub-op DID supply a sheet selector, so a missing-locator error
|
||||
// would mean the fixture is wrong and the business-required check never
|
||||
// actually ran — reject that shape so the parity check stays honest.
|
||||
if strings.Contains(err.Error(), "specify at least one of") {
|
||||
t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
342
shortcuts/sheets/batch_op_dispatch.go
Normal file
342
shortcuts/sheets/batch_op_dispatch.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
|
||||
//
|
||||
// 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}:
|
||||
//
|
||||
// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...]
|
||||
//
|
||||
// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 /
|
||||
// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是
|
||||
// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同
|
||||
// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range)。
|
||||
//
|
||||
// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator**
|
||||
// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作
|
||||
// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由
|
||||
// batch-vs-standalone 契约测试保证)。dispatch 表只列**可纳入 atomic batch
|
||||
// 的 write shortcut**——读操作、fan-out wrapper(+batch-update 自身、
|
||||
// +cells-batch-set-style、+cells-batch-clear、+dropdown-{update,delete})一律不放进表里,
|
||||
// 用户传到 +batch-update 里会被 translator 拒绝。
|
||||
|
||||
// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP
|
||||
// tool body for the underlying batch_update sub-tool. token is the
|
||||
// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved
|
||||
// sheet selector for this sub-op. The returned body already carries excel_id
|
||||
// and (where the tool needs one) the operation discriminator — exactly as the
|
||||
// standalone shortcut would emit.
|
||||
type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error)
|
||||
|
||||
type batchOpMapping struct {
|
||||
// mcpToolName 是底层 MCP batch_update 接受的 tool_name。
|
||||
mcpToolName string
|
||||
// translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。
|
||||
translate batchTranslateFn
|
||||
}
|
||||
|
||||
// sheetSelectorFlagsForSubOp returns the (id, name) flag names a +batch-update
|
||||
// sub-op uses to express its placement / context sheet. Defaults are
|
||||
// `sheet-id` / `sheet-name`; +pivot-create deviates because its create
|
||||
// shortcut renamed the placement selector to `target-sheet-id` /
|
||||
// `target-sheet-name` (the data-source sheet is encoded in --source as
|
||||
// `'SheetName'!Range`, not in a sheet selector flag). Update / delete on
|
||||
// pivot still use the default names — only the create create-side
|
||||
// shortcut was renamed.
|
||||
func sheetSelectorFlagsForSubOp(shortcut string) (string, string) {
|
||||
if shortcut == "+pivot-create" {
|
||||
return "target-sheet-id", "target-sheet-name"
|
||||
}
|
||||
return "sheet-id", "sheet-name"
|
||||
}
|
||||
|
||||
// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object
|
||||
// CRUD spec to the shared object_crud builders.
|
||||
func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
return objectCreateInput(fv, token, sheetID, sheetName, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
return objectUpdateInput(fv, token, sheetID, sheetName, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
|
||||
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
return objectDeleteInput(fv, token, sheetID, sheetName, spec)
|
||||
}
|
||||
}
|
||||
|
||||
// batchOpDispatch covers every write shortcut that can join an atomic batch.
|
||||
// Each entry plugs the shortcut's standalone xxxInput builder into the
|
||||
// batch translator path — so the body is byte-identical to the standalone
|
||||
// invocation (locked by TestBatchOp_BodyMatchesStandalone) and the missing-
|
||||
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
|
||||
var batchOpDispatch = map[string]batchOpMapping{
|
||||
// ─── 单元格内容 ──────────────────────────────────────────────────
|
||||
"+cells-set": {"set_cell_range", cellsSetInput},
|
||||
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
|
||||
"+cells-clear": {"clear_cell_range", cellsClearInput},
|
||||
"+cells-replace": {"replace_data", replaceInput},
|
||||
"+csv-put": {"set_range_from_csv", csvPutInput},
|
||||
"+dropdown-set": {"set_cell_range", dropdownSetInput},
|
||||
|
||||
// ─── 单元格合并 (merge_cells, operation 区分) ────────────────────
|
||||
"+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return mergeInput(fv, token, sid, sname, "merge", true)
|
||||
}},
|
||||
"+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return mergeInput(fv, token, sid, sname, "unmerge", false)
|
||||
}},
|
||||
|
||||
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
|
||||
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
|
||||
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimRangeOpInput(fv, token, sid, sname, "delete")
|
||||
}},
|
||||
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimRangeOpInput(fv, token, sid, sname, "hide")
|
||||
}},
|
||||
"+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimRangeOpInput(fv, token, sid, sname, "unhide")
|
||||
}},
|
||||
"+dim-freeze": {"modify_sheet_structure", dimFreezeInput},
|
||||
"+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimGroupInput(fv, token, sid, sname, "group")
|
||||
}},
|
||||
"+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return dimGroupInput(fv, token, sid, sname, "ungroup")
|
||||
}},
|
||||
|
||||
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
|
||||
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return resizeInput(fv, token, sid, sname, "row")
|
||||
}},
|
||||
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return resizeInput(fv, token, sid, sname, "column")
|
||||
}},
|
||||
|
||||
// ─── 区域操作 (transform_range, operation 区分) ─────────────────
|
||||
"+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return transformMoveCopyInput(fv, token, sid, sname, "move", false)
|
||||
}},
|
||||
"+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
return transformMoveCopyInput(fv, token, sid, sname, "copy", true)
|
||||
}},
|
||||
"+range-fill": {"transform_range", rangeFillInput},
|
||||
"+range-sort": {"transform_range", rangeSortInput},
|
||||
|
||||
// ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ──
|
||||
"+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) {
|
||||
return sheetCreateInput(fv, token)
|
||||
}},
|
||||
"+sheet-delete": {"modify_workbook_structure", sheetDeleteInput},
|
||||
"+sheet-rename": {"modify_workbook_structure", sheetRenameInput},
|
||||
"+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput},
|
||||
"+sheet-copy": {"modify_workbook_structure", sheetCopyInput},
|
||||
"+sheet-hide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide")
|
||||
}},
|
||||
"+sheet-unhide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
|
||||
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
|
||||
|
||||
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
|
||||
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},
|
||||
"+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)},
|
||||
|
||||
"+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)},
|
||||
"+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)},
|
||||
"+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)},
|
||||
|
||||
"+filter-create": {"manage_filter_object", filterCreateInput},
|
||||
"+filter-update": {"manage_filter_object", filterUpdateInput},
|
||||
"+filter-delete": {"manage_filter_object", filterDeleteInput},
|
||||
|
||||
"+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)},
|
||||
"+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)},
|
||||
"+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)},
|
||||
|
||||
"+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)},
|
||||
"+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)},
|
||||
"+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)},
|
||||
|
||||
"+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectLocalImageInBatch(fv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return floatImageWriteInput(fv, token, sid, sname, "create", false, "")
|
||||
}},
|
||||
"+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
|
||||
if err := rejectLocalImageInBatch(fv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return floatImageWriteInput(fv, token, sid, sname, "update", true, "")
|
||||
}},
|
||||
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
|
||||
}
|
||||
|
||||
// rejectLocalImageInBatch blocks the local-file --image source inside
|
||||
// +batch-update: a batch sub-op has no upload phase, so the file could not be
|
||||
// turned into a file_token. Callers must pass --image-token / --image-uri.
|
||||
func rejectLocalImageInBatch(fv flagView) error {
|
||||
if strings.TrimSpace(fv.Str("image")) != "" {
|
||||
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the
|
||||
// standalone shortcut it cannot issue the get_workbook_structure read that
|
||||
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
|
||||
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if sheetID == "" {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
|
||||
}
|
||||
if !fv.Changed("source-index") {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
|
||||
}
|
||||
if fv.Int("source-index") < 0 {
|
||||
return nil, common.FlagErrorf("--source-index must be >= 0")
|
||||
}
|
||||
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
|
||||
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
|
||||
// which would silently move the sheet to the front. Require it explicitly so
|
||||
// the batch contract matches the standalone one.
|
||||
if !fv.Changed("index") {
|
||||
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
|
||||
}
|
||||
if fv.Int("index") < 0 {
|
||||
return nil, common.FlagErrorf("--index must be >= 0")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "move",
|
||||
"sheet_id": sheetID,
|
||||
"source_index": fv.Int("source-index"),
|
||||
"target_index": fv.Int("index"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由
|
||||
// +batch-update 顶层 --url/--token 统一提供(excel_id / spreadsheet_token / url)。
|
||||
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
|
||||
|
||||
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
|
||||
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
|
||||
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
|
||||
// translator 翻成 MCP body。
|
||||
//
|
||||
// 失败场景:
|
||||
// - shortcut 字段缺失 / 非 string
|
||||
// - shortcut 不在 dispatch 表(拼写错;read 操作;嵌套 fan-out wrapper)
|
||||
// - input 不是 object
|
||||
// - input 里手填了 operation(由 shortcut 名隐含,禁手填以防 mismatch)
|
||||
// - input 里手填了 excel_id / spreadsheet_token / url
|
||||
// - 子操作的 translator 报错(如缺必填字段)
|
||||
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
|
||||
op, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
|
||||
}
|
||||
scRaw, present := op["shortcut"]
|
||||
if !present {
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
|
||||
}
|
||||
sc, ok := scRaw.(string)
|
||||
if !ok || sc == "" {
|
||||
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
|
||||
}
|
||||
mapping, ok := batchOpDispatch[sc]
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf(
|
||||
"operations[%d]: shortcut %q not allowed in +batch-update "+
|
||||
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
|
||||
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
|
||||
index, sc,
|
||||
)
|
||||
}
|
||||
inputRaw, hasInput := op["input"]
|
||||
var input map[string]interface{}
|
||||
if !hasInput || inputRaw == nil {
|
||||
input = map[string]interface{}{}
|
||||
} else {
|
||||
input, ok = inputRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
|
||||
}
|
||||
}
|
||||
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
|
||||
if _, has := input["operation"]; has {
|
||||
return nil, common.FlagErrorf(
|
||||
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
|
||||
index, sc,
|
||||
)
|
||||
}
|
||||
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
|
||||
for _, k := range reservedSubOpKeys {
|
||||
if _, has := input[k]; has {
|
||||
return nil, common.FlagErrorf(
|
||||
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
|
||||
index, sc, k,
|
||||
)
|
||||
}
|
||||
}
|
||||
// 拒绝任何额外的 sub-op 顶层 key(防御未来 schema drift / 用户笔误)。
|
||||
for k := range op {
|
||||
if k != "shortcut" && k != "input" {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
|
||||
}
|
||||
}
|
||||
fv := newMapFlagViewForCommand(sc, input)
|
||||
// operations is skipped by parse-time schema validation, so type-check the
|
||||
// sub-op's scalar fields here before the translator reads them via
|
||||
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
|
||||
if err := fv.validateRawTypes(); err != nil {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
|
||||
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
|
||||
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
|
||||
body, err := mapping.translate(fv, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": mapping.mcpToolName,
|
||||
"input": body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// translateBatchOperations 翻译整个 ops 数组;fail-fast,遇错立即返回。
|
||||
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
|
||||
if len(rawOps) == 0 {
|
||||
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
|
||||
}
|
||||
out := make([]interface{}, 0, len(rawOps))
|
||||
for i, raw := range rawOps {
|
||||
translated, err := translateBatchOp(raw, token, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, translated)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
83
shortcuts/sheets/csv_put_range_alias_test.go
Normal file
83
shortcuts/sheets/csv_put_range_alias_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// +csv-put locates with --start-cell, while +csv-get / +cells-set locate with
|
||||
// --range. Agents routinely carry --range over to +csv-put and hit a guaranteed
|
||||
// first-try failure. csvPutInput now accepts --range as an alias for
|
||||
// --start-cell; a range value collapses to its top-left cell.
|
||||
func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw map[string]interface{}
|
||||
wantAnchor string
|
||||
}{
|
||||
{"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"},
|
||||
{"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"},
|
||||
{"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"},
|
||||
{"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", tt.raw)
|
||||
input, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("csvPutInput returned error: %v", err)
|
||||
}
|
||||
got, _ := input["start_cell"].(string)
|
||||
if got != tt.wantAnchor {
|
||||
t.Errorf("start_cell = %q, want %q", got, tt.wantAnchor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
|
||||
// call instead of silently anchoring at the "A1" flag default. Standalone never
|
||||
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it
|
||||
// first — but a +batch-update sub-op skips cobra, so the guard must live in the
|
||||
// shared builder too. Otherwise a batch +csv-put with no anchor silently pastes
|
||||
// at A1, diverging from the standalone contract.
|
||||
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
|
||||
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
|
||||
// how far a CSV reaches from its anchor — it auto-expands to the CSV's own size,
|
||||
// not to any user-set range.
|
||||
func TestCsvPutWriteRangeFromInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]interface{}
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"3x3 at B2", map[string]interface{}{"start_cell": "B2", "csv": "a,b,c\n1,2,3\n4,5,6"}, "B2:D4", true},
|
||||
{"single cell at A1", map[string]interface{}{"start_cell": "A1", "csv": "x"}, "A1:A1", true},
|
||||
{"1 row 3 cols at C3", map[string]interface{}{"start_cell": "C3", "csv": "a,b,c"}, "C3:E3", true},
|
||||
{"ragged rows use max width", map[string]interface{}{"start_cell": "A1", "csv": "a,b\nc,d,e"}, "A1:C2", true},
|
||||
{"missing csv", map[string]interface{}{"start_cell": "A1"}, "", false},
|
||||
{"non-single anchor", map[string]interface{}{"start_cell": "A1:B2", "csv": "x"}, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := csvPutWriteRangeFromInput(tt.input)
|
||||
if ok != tt.ok || got != tt.want {
|
||||
t.Errorf("got (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
4542
shortcuts/sheets/data/flag-defs.json
Normal file
4542
shortcuts/sheets/data/flag-defs.json
Normal file
File diff suppressed because it is too large
Load Diff
6254
shortcuts/sheets/data/flag-schemas.json
Normal file
6254
shortcuts/sheets/data/flag-schemas.json
Normal file
File diff suppressed because it is too large
Load Diff
578
shortcuts/sheets/execute_paths_test.go
Normal file
578
shortcuts/sheets/execute_paths_test.go
Normal file
@@ -0,0 +1,578 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
||||
// verifies the shortcut decodes the JSON-string output, surfaces it as
|
||||
// envelope data, and finishes without error.
|
||||
func TestExecute_WorkbookInfo_Happy(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) != 1 {
|
||||
t.Fatalf("sheets len = %d, want 1", len(sheets))
|
||||
}
|
||||
sheet, _ := sheets[0].(map[string]interface{})
|
||||
if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" {
|
||||
t.Errorf("unexpected sheet: %#v", sheet)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the
|
||||
// envelope shape and asserts CLI returns an error envelope.
|
||||
func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1310201,
|
||||
"msg": "spreadsheet not found",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
stdout, stderr, err := func() (string, string, error) {
|
||||
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
|
||||
reg.Register(stub)
|
||||
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
|
||||
err := parent.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}()
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
|
||||
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
|
||||
// when only --sheet-name is given (and --source-index omitted) first
|
||||
// reads the workbook structure to derive sheet_id + source_index, then
|
||||
// posts the modify_workbook_structure call.
|
||||
func TestExecute_SheetMove_LookupsIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","index":3}]}`)
|
||||
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
|
||||
out, err := runShortcutWithStubs(t, SheetMove,
|
||||
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
|
||||
lookup, move,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
// Inspect the captured move body: source_index should be 3 (looked up),
|
||||
// not <resolve>, and sheet_id should be the resolved id.
|
||||
if move.CapturedBody == nil {
|
||||
t.Fatal("move stub didn't capture a body")
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
||||
input := decodeToolInput(t, body, "modify_workbook_structure")
|
||||
if input["sheet_id"] != "sh1" {
|
||||
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name)", input["sheet_id"])
|
||||
}
|
||||
if input["source_index"].(float64) != 3 {
|
||||
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
|
||||
}
|
||||
if input["target_index"].(float64) != 0 {
|
||||
t.Errorf("target_index = %v, want 0", input["target_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as
|
||||
// above but with get_workbook_structure exposing the display name as "title"
|
||||
// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex
|
||||
// must resolve --sheet-name against either key.
|
||||
func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`)
|
||||
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
|
||||
out, err := runShortcutWithStubs(t, SheetMove,
|
||||
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
|
||||
lookup, move,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
if move.CapturedBody == nil {
|
||||
t.Fatal("move stub didn't capture a body")
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
||||
input := decodeToolInput(t, body, "modify_workbook_structure")
|
||||
if input["sheet_id"] != "sh1" {
|
||||
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"])
|
||||
}
|
||||
if input["source_index"].(float64) != 3 {
|
||||
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_CellsGet covers a multi-range read end-to-end.
|
||||
func TestExecute_CellsGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`)
|
||||
out, err := runShortcutWithStubs(t, CellsGet,
|
||||
[]string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
if data := decodeEnvelopeData(t, out); data["ranges"] == nil {
|
||||
t.Fatalf("expected ranges in output; got=%#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_CellsSet covers the write path including allow-overwrite
|
||||
// override.
|
||||
func TestExecute_CellsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`)
|
||||
out, err := runShortcutWithStubs(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:B1",
|
||||
"--cells", `[[{"value":"x"},{"value":"y"}]]`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B1" {
|
||||
t.Errorf("wire range = %v", input["range"])
|
||||
}
|
||||
if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 {
|
||||
t.Errorf("updated_cells = %v", data["updated_cells"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_DropdownSet covers the fan-out → set_cell_range write.
|
||||
func TestExecute_DropdownSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{}`)
|
||||
_, err := runShortcutWithStubs(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A2:A4",
|
||||
"--options", `["x","y"]`,
|
||||
"--multiple",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Errorf("wire cells rows = %d, want 3", len(cells))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for
|
||||
// dropdown-update. Verifies the captured request has 2 ops.
|
||||
func TestExecute_DropdownUpdate_Batch(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`)
|
||||
_, err := runShortcutWithStubs(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
|
||||
"--options", `["a","b"]`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 2 {
|
||||
t.Errorf("operations len = %d, want 2", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_CellsSearch covers the search read path with options.
|
||||
func TestExecute_CellsSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`)
|
||||
out, err := runShortcutWithStubs(t, CellsSearch, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--find", "foo", "--match-case",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["matches"] == nil {
|
||||
t.Errorf("matches missing: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeMove covers the transform_range write path.
|
||||
func TestExecute_RangeMove(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"moved":true}`)
|
||||
out, err := runShortcutWithStubs(t, RangeMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "A1:C5",
|
||||
"--target-range", "D1",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "transform_range")
|
||||
if input["operation"] != "move" {
|
||||
t.Errorf("operation = %v, want move", input["operation"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_FilterCreate covers the filter special case (range mandatory,
|
||||
// optional --data conditions merge).
|
||||
func TestExecute_FilterCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`)
|
||||
out, err := runShortcutWithStubs(t, FilterCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:F100",
|
||||
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "manage_filter_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props["range"] != "A1:F100" {
|
||||
t.Errorf("properties.range = %v", props["range"])
|
||||
}
|
||||
if props["rules"] == nil {
|
||||
t.Errorf("rules missing: %#v", props)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape
|
||||
// translation: user passes {shortcut, input}, batchOpDispatch maps it to
|
||||
// {tool_name, input(+operation, +excel_id)} before the tool call. Also
|
||||
// verifies --continue-on-error.
|
||||
func TestExecute_BatchUpdate_Translated(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
||||
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`,
|
||||
"--continue-on-error",
|
||||
"--yes",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
if input["continue_on_error"] != true {
|
||||
t.Errorf("continue_on_error not propagated: %#v", input)
|
||||
}
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "set_cell_range" {
|
||||
t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"])
|
||||
}
|
||||
subInput, _ := op["input"].(map[string]interface{})
|
||||
if subInput["excel_id"] != testToken {
|
||||
t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken)
|
||||
}
|
||||
if _, has := subInput["operation"]; has {
|
||||
t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope
|
||||
// precedence: an explicit --continue-on-error=false must keep the strict
|
||||
// transaction even when the --operations envelope carries continue_on_error:true,
|
||||
// while an envelope value still applies when the flag is absent. Guards against
|
||||
// the regression where the flag was read by value (runtime.Bool) rather than by
|
||||
// Changed().
|
||||
func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) {
|
||||
t.Parallel()
|
||||
envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}`
|
||||
|
||||
t.Run("explicit false overrides envelope", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
||||
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", envelope,
|
||||
"--continue-on-error=false",
|
||||
"--yes",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
|
||||
if input["continue_on_error"] == true {
|
||||
t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("envelope applies when flag absent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
||||
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", envelope,
|
||||
"--yes",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
|
||||
if input["continue_on_error"] != true {
|
||||
t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup +
|
||||
// set_cell_range follow-up. Stubs all three endpoints.
|
||||
func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcnBRAND",
|
||||
"title": "Sales",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
ss, _ := data["spreadsheet"].(map[string]interface{})
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
if fillInput["sheet_id"] != "shtFirst" {
|
||||
t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Only the create stub is provided: an empty array must skip the fill
|
||||
// entirely, so no structure/fill call fires (and no nil-map panic).
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
|
||||
// contract: when the spreadsheet is created but the follow-up fill can't resolve
|
||||
// its first sheet, the error must be structured and retain spreadsheet_token so
|
||||
// the caller can recover instead of orphaning the new workbook.
|
||||
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
|
||||
// spreadsheet already exists — exercising the partial-success path.
|
||||
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
|
||||
if err == nil {
|
||||
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
|
||||
}
|
||||
exitErr, ok := err.(*output.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
|
||||
if detail["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_DimMove covers the native v3 move_dimension call. CLI's
|
||||
// --source-range "1:3" (1-based inclusive) is parsed into v3's
|
||||
// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is
|
||||
// parsed into destination_index=10.
|
||||
func TestExecute_DimMove(t *testing.T) {
|
||||
t.Parallel()
|
||||
move := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"moved": true},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, DimMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "1:3", "--target", "11",
|
||||
}, move)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
||||
src, _ := body["source"].(map[string]interface{})
|
||||
if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 {
|
||||
t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"])
|
||||
}
|
||||
if body["destination_index"].(float64) != 10 {
|
||||
t.Errorf("destination_index = %v, want 10", body["destination_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_ChartCreate covers the object-CRUD factory's create path.
|
||||
func TestExecute_ChartCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`)
|
||||
out, err := runShortcutWithStubs(t, ChartCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["chart_id"] != "chartNEW" {
|
||||
t.Errorf("chart_id = %v", data["chart_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetCreate hits the workbook write path with all four
|
||||
// optional flags so the input builder + callTool wiring is exercised.
|
||||
func TestExecute_SheetCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`)
|
||||
out, err := runShortcutWithStubs(t, SheetCreate, []string{
|
||||
"--url", testURL,
|
||||
"--title", "Q4",
|
||||
"--index", "2",
|
||||
"--row-count", "300",
|
||||
"--col-count", "12",
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "modify_workbook_structure")
|
||||
if input["operation"] != "create" || input["sheet_name"] != "Q4" {
|
||||
t.Errorf("input shape wrong: %#v", input)
|
||||
}
|
||||
if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 {
|
||||
t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeSort exercises the sort_conditions JSON parsing
|
||||
// alongside the boolean has_header.
|
||||
func TestExecute_RangeSort(t *testing.T) {
|
||||
t.Parallel()
|
||||
stub := toolOutputStub(testToken, "write", `{"sorted":true}`)
|
||||
_, err := runShortcutWithStubs(t, RangeSort, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:D50",
|
||||
"--has-header",
|
||||
"--sort-keys", `[{"column":"B","ascending":true}]`,
|
||||
}, stub)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
||||
input := decodeToolInput(t, body, "transform_range")
|
||||
if input["operation"] != "sort" || input["has_header"] != true {
|
||||
t.Errorf("input wrong: %#v", input)
|
||||
}
|
||||
conds, _ := input["sort_conditions"].([]interface{})
|
||||
if len(conds) != 1 {
|
||||
t.Errorf("sort_conditions len = %d", len(conds))
|
||||
}
|
||||
}
|
||||
|
||||
// decodeRawEnvelopeBody parses the raw JSON request body captured by an
|
||||
// httpmock stub. Used by execute tests to inspect what the CLI sent on
|
||||
// the wire (vs. dry-run tests that render the body up-front).
|
||||
func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} {
|
||||
t.Helper()
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw))
|
||||
}
|
||||
return body
|
||||
}
|
||||
82
shortcuts/sheets/flag_defs.go
Normal file
82
shortcuts/sheets/flag_defs.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── flag definitions, sourced from sheet-skill-spec ───────────────────
|
||||
//
|
||||
// data/flag-defs.json is the canonical, full definition of every CLI flag
|
||||
// (name, type, default, desc, enum, input, hidden, required, kind),
|
||||
// generated by sheet-skill-spec's sync script. The sync script also emits
|
||||
// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays
|
||||
// no JSON unmarshal (the parse cost used to land on every CLI invocation,
|
||||
// sheets or not). We build each shortcut's []common.Flag from flagDefs at
|
||||
// assembly time, so flag metadata never has to be hand-written in Go.
|
||||
//
|
||||
// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized
|
||||
// here: the framework auto-injects them based on Risk / DryRun / HasFormat.
|
||||
// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via
|
||||
// the sync script. flag_defs_gen_test.go guards the two against drift.
|
||||
|
||||
type flagDef struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // "public" | "own" | "system"
|
||||
Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice
|
||||
Required string `json:"required"` // "required" | "optional" | "xor"
|
||||
Desc string `json:"desc"`
|
||||
Default string `json:"default"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Enum []string `json:"enum"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
type commandDef struct {
|
||||
Risk string `json:"risk"`
|
||||
Flags []flagDef `json:"flags"`
|
||||
}
|
||||
|
||||
// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go).
|
||||
// The error return is always nil; it is retained so existing call sites that
|
||||
// handled a parse error keep compiling. There is no longer a runtime parse.
|
||||
func loadFlagDefs() (map[string]commandDef, error) {
|
||||
return flagDefs, nil
|
||||
}
|
||||
|
||||
// flagsFor builds the []common.Flag for a shortcut command directly from
|
||||
// flag-defs.json. System-kind flags are skipped (the framework injects
|
||||
// them). Panics if the command is absent or the JSON is malformed — this
|
||||
// is a build-time data contract, so a missing entry is a programming error
|
||||
// surfaced loudly at startup rather than a silent empty flag set.
|
||||
func flagsFor(command string) []common.Flag {
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("sheets: %v", err))
|
||||
}
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command))
|
||||
}
|
||||
out := make([]common.Flag, 0, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" {
|
||||
continue
|
||||
}
|
||||
out = append(out, common.Flag{
|
||||
Name: df.Name,
|
||||
Type: df.Type,
|
||||
Default: df.Default,
|
||||
Desc: df.Desc,
|
||||
Hidden: df.Hidden,
|
||||
Required: df.Required == "required",
|
||||
Enum: df.Enum,
|
||||
Input: df.Input,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
927
shortcuts/sheets/flag_defs_gen.go
Normal file
927
shortcuts/sheets/flag_defs_gen.go
Normal file
@@ -0,0 +1,927 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-defs.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
|
||||
// metadata for every shortcut, emitted as a Go literal so command startup
|
||||
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
|
||||
// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json
|
||||
// changes.
|
||||
var flagDefs = map[string]commandDef{
|
||||
"+batch-update": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
|
||||
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
|
||||
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
|
||||
},
|
||||
},
|
||||
"+cells-batch-clear": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-batch-set-style": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
|
||||
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-clear": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
|
||||
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-merge": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-replace": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
|
||||
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
|
||||
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
|
||||
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"},
|
||||
},
|
||||
},
|
||||
"+cells-search": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
|
||||
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
|
||||
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
|
||||
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
|
||||
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"},
|
||||
{Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true},
|
||||
{Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
|
||||
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
|
||||
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set-image": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-set-style": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
|
||||
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
|
||||
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
|
||||
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
|
||||
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
|
||||
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
|
||||
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
|
||||
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
|
||||
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
|
||||
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
|
||||
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
|
||||
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cells-unmerge": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
|
||||
},
|
||||
},
|
||||
"+chart-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+chart-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cols-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+cond-format-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
|
||||
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+csv-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
"+csv-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-freeze": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-group": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
|
||||
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-hide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-insert": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
|
||||
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
|
||||
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-ungroup": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dim-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-set": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"},
|
||||
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+dropdown-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
|
||||
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
|
||||
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+filter-view-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
|
||||
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
|
||||
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
|
||||
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
|
||||
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
|
||||
{Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+float-image-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
|
||||
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
|
||||
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
|
||||
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
|
||||
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
|
||||
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
|
||||
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
|
||||
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
|
||||
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
|
||||
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
|
||||
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}},
|
||||
{Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
|
||||
{Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
|
||||
{Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+pivot-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-copy": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
{Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-fill": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
|
||||
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
|
||||
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
|
||||
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+range-sort": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
|
||||
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+rows-resize": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
|
||||
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-copy": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-move": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
|
||||
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-rename": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-set-tab-color": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-delete": {
|
||||
Risk: "high-risk-write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-list": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sparkline-update": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
|
||||
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-export": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
}
|
||||
33
shortcuts/sheets/flag_defs_gen_test.go
Normal file
33
shortcuts/sheets/flag_defs_gen_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// flagDefsJSONForTest embeds the source data only in tests; production code
|
||||
// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals.
|
||||
//
|
||||
//go:embed data/flag-defs.json
|
||||
var flagDefsJSONForTest []byte
|
||||
|
||||
// TestFlagDefsGen_MatchesJSON guards against drift between the compiled
|
||||
// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the
|
||||
// JSON is regenerated without re-running the codegen (or vice versa), this
|
||||
// fails. This equivalence is exactly what lets production code skip the
|
||||
// runtime unmarshal.
|
||||
func TestFlagDefsGen_MatchesJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
var fromJSON map[string]commandDef
|
||||
if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil {
|
||||
t.Fatalf("unmarshal flag-defs.json: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromJSON, flagDefs) {
|
||||
t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go")
|
||||
}
|
||||
}
|
||||
142
shortcuts/sheets/flag_defs_test.go
Normal file
142
shortcuts/sheets/flag_defs_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestFlagDefs_EmbedParses asserts the embedded flag-defs.json blob is valid
|
||||
// JSON with at least one command entry.
|
||||
func TestFlagDefs_EmbedParses(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagDefs error: %v", err)
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
t.Fatal("flag-defs.json has no command entries")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagsFor_SkipsSystemFlags verifies system-kind flags (--dry-run, --yes)
|
||||
// are never materialized into a shortcut's Flags slice — the framework injects
|
||||
// those based on Risk / DryRun.
|
||||
func TestFlagsFor_SkipsSystemFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, cmd := range []string{"+sheet-delete", "+batch-update", "+csv-get"} {
|
||||
for _, f := range flagsFor(cmd) {
|
||||
if f.Name == "dry-run" || f.Name == "yes" {
|
||||
t.Errorf("%s: system flag --%s leaked into Flags", cmd, f.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagsFor_MapsAllFields spot-checks that name/type/default/enum/input/
|
||||
// required/hidden are carried over from the JSON correctly.
|
||||
func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
byName := func(cmd, name string) *common.Flag {
|
||||
flags := flagsFor(cmd)
|
||||
for i := range flags {
|
||||
if flags[i].Name == name {
|
||||
return &flags[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enum + default
|
||||
rt := byName("+dim-insert", "inherit-style")
|
||||
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
|
||||
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
|
||||
}
|
||||
// required
|
||||
title := byName("+sheet-create", "title")
|
||||
if title == nil || !title.Required {
|
||||
t.Errorf("+sheet-create --title should be required: %+v", title)
|
||||
}
|
||||
// xor is NOT cobra-required (enforced by Validate hooks)
|
||||
url := byName("+sheet-create", "url")
|
||||
if url == nil || url.Required {
|
||||
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
|
||||
}
|
||||
// hidden + int default
|
||||
cap := byName("+cells-get", "max-chars")
|
||||
if cap == nil || !cap.Hidden || cap.Default != "200000" {
|
||||
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
|
||||
}
|
||||
// input sources
|
||||
cells := byName("+cells-set", "cells")
|
||||
if cells == nil || len(cells.Input) != 2 {
|
||||
t.Errorf("+cells-set --cells should support file+stdin: %+v", cells)
|
||||
}
|
||||
// float64 type
|
||||
fs := byName("+cells-set-style", "font-size")
|
||||
if fs == nil || fs.Type != "float64" {
|
||||
t.Errorf("+cells-set-style --font-size should be float64: %+v", fs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagsFor_EveryRegisteredCommandHasDefs ensures every shortcut returned by
|
||||
// Shortcuts() has a flag-defs.json entry and that its flags match the JSON's
|
||||
// non-system flags exactly (name + type + required + default + hidden). This is
|
||||
// the contract that lets shortcuts drop hand-written flag literals.
|
||||
func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range Shortcuts() {
|
||||
spec, ok := defs[s.Command]
|
||||
if !ok {
|
||||
t.Errorf("%s has no flag-defs.json entry", s.Command)
|
||||
continue
|
||||
}
|
||||
want := map[string]flagDef{}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind != "system" {
|
||||
want[df.Name] = df
|
||||
}
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, f := range s.Flags {
|
||||
got[f.Name] = true
|
||||
df, ok := want[f.Name]
|
||||
if !ok {
|
||||
t.Errorf("%s --%s present in Go but not in JSON (non-system)", s.Command, f.Name)
|
||||
continue
|
||||
}
|
||||
ft := f.Type
|
||||
if ft == "" {
|
||||
ft = "string"
|
||||
}
|
||||
jt := df.Type
|
||||
if jt == "" {
|
||||
jt = "string"
|
||||
}
|
||||
if ft != jt {
|
||||
t.Errorf("%s --%s type: go=%s json=%s", s.Command, f.Name, ft, jt)
|
||||
}
|
||||
if f.Required != (df.Required == "required") {
|
||||
t.Errorf("%s --%s required: go=%v json=%s", s.Command, f.Name, f.Required, df.Required)
|
||||
}
|
||||
if f.Default != df.Default {
|
||||
t.Errorf("%s --%s default: go=%q json=%q", s.Command, f.Name, f.Default, df.Default)
|
||||
}
|
||||
if f.Hidden != df.Hidden {
|
||||
t.Errorf("%s --%s hidden: go=%v json=%v", s.Command, f.Name, f.Hidden, df.Hidden)
|
||||
}
|
||||
}
|
||||
for name := range want {
|
||||
if !got[name] {
|
||||
t.Errorf("%s --%s in JSON but missing from Go Flags", s.Command, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
shortcuts/sheets/flag_schema.go
Normal file
124
shortcuts/sheets/flag_schema.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ─── --print-schema runtime introspection ─────────────────────────────
|
||||
//
|
||||
// Composite JSON flags (--cells, --properties, --operations, --border-styles,
|
||||
// --sort-keys) carry non-trivial structured payloads. Reference docs cover
|
||||
// the top-level fields but agents often need the full JSON Schema to
|
||||
// generate valid input.
|
||||
//
|
||||
// To serve that need without forcing every caller to fetch external docs,
|
||||
// the spec repo ships a compact `flag-schemas.json` that extracts just the
|
||||
// schema subtree corresponding to each (shortcut, flag) pair. We embed
|
||||
// that artifact at compile time so `lark-cli sheets <shortcut>
|
||||
// --print-schema --flag-name <name>` runs entirely locally.
|
||||
//
|
||||
// The artifact is generated by sheet-skill-spec's
|
||||
// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json
|
||||
// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json;
|
||||
// regenerate via the sync script.
|
||||
|
||||
//go:embed data/flag-schemas.json
|
||||
var flagSchemasJSON []byte
|
||||
|
||||
// flagSchemaIndex parses lazily on first access; failures are surfaced
|
||||
// as errors from the lookup helper rather than panicking at init time.
|
||||
type flagSchemaIndex struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Flags map[string]map[string]json.RawMessage `json:"flags"`
|
||||
}
|
||||
|
||||
// loadFlagSchemas is sync.Once-guarded so concurrent first access from
|
||||
// parallel goroutines (e.g. parallel unit tests, parallel shortcut
|
||||
// invocations) doesn't race on the lazy parse.
|
||||
var (
|
||||
flagSchemasOnce sync.Once
|
||||
parsedFlagSchemas *flagSchemaIndex
|
||||
parseFlagErr error
|
||||
)
|
||||
|
||||
func loadFlagSchemas() (*flagSchemaIndex, error) {
|
||||
flagSchemasOnce.Do(func() {
|
||||
var idx flagSchemaIndex
|
||||
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
|
||||
parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
|
||||
return
|
||||
}
|
||||
if idx.Flags == nil {
|
||||
idx.Flags = map[string]map[string]json.RawMessage{}
|
||||
}
|
||||
parsedFlagSchemas = &idx
|
||||
})
|
||||
return parsedFlagSchemas, parseFlagErr
|
||||
}
|
||||
|
||||
// commandsWithFlagSchema returns the set of shortcut commands that have
|
||||
// at least one introspectable flag. Used by Shortcuts() to decide which
|
||||
// shortcuts to wire PrintFlagSchema into.
|
||||
func commandsWithFlagSchema() map[string]struct{} {
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil || idx == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(idx.Flags))
|
||||
for cmd := range idx.Flags {
|
||||
out[cmd] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given
|
||||
// shortcut command. When flagName == "" the closure returns a JSON
|
||||
// listing of introspectable flags; otherwise it returns the schema
|
||||
// subtree JSON for the named flag, or an error if the flag is not
|
||||
// registered.
|
||||
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
return func(flagName string) ([]byte, error) {
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
|
||||
}
|
||||
if flagName == "" {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return json.MarshalIndent(map[string]interface{}{
|
||||
"shortcut": command,
|
||||
"introspectable_flags": flags,
|
||||
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
|
||||
}, "", " ")
|
||||
}
|
||||
schema, ok := entry[flagName]
|
||||
if !ok {
|
||||
flags := make([]string, 0, len(entry))
|
||||
for f := range entry {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
if err := json.Unmarshal(schema, &pretty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.MarshalIndent(pretty, "", " ")
|
||||
}
|
||||
}
|
||||
209
shortcuts/sheets/flag_schema_test.go
Normal file
209
shortcuts/sheets/flag_schema_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestFlagSchemas_EmbedParses asserts the synced flag-schemas.json
|
||||
// embedded blob is valid JSON and has at least one shortcut/flag entry.
|
||||
// If sync_to_consumers.mjs ever ships an empty or broken artifact, this
|
||||
// catches it at build time of the test binary.
|
||||
func TestFlagSchemas_EmbedParses(t *testing.T) {
|
||||
t.Parallel()
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas error: %v", err)
|
||||
}
|
||||
if idx == nil || len(idx.Flags) == 0 {
|
||||
t.Fatalf("flag-schemas.json has no entries")
|
||||
}
|
||||
if idx.SchemaVersion == "" {
|
||||
t.Errorf("schema_version missing")
|
||||
}
|
||||
// Spot-check a couple of canonical entries we know upstream guarantees.
|
||||
for _, want := range []string{"+cells-set", "+chart-create", "+batch-update"} {
|
||||
if _, ok := idx.Flags[want]; !ok {
|
||||
t.Errorf("missing shortcut entry %q (regenerate via sheet-skill-spec/scripts/sync_to_consumers.mjs)", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_ListIntrospectable verifies that calling the
|
||||
// closure with an empty flag name returns the JSON listing of
|
||||
// introspectable flags for the shortcut.
|
||||
func TestPrintFlagSchema_ListIntrospectable(t *testing.T) {
|
||||
t.Parallel()
|
||||
out, err := printFlagSchemaFor("+cells-set")("")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("output not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if got["shortcut"] != "+cells-set" {
|
||||
t.Errorf("shortcut = %v, want +cells-set", got["shortcut"])
|
||||
}
|
||||
flags, _ := got["introspectable_flags"].([]interface{})
|
||||
if len(flags) == 0 || flags[0] != "cells" {
|
||||
t.Errorf("introspectable_flags = %v, want [cells]", flags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree verifies a hit on
|
||||
// (+chart-create, properties) yields a JSON Schema object with the
|
||||
// expected top-level fields.
|
||||
func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
|
||||
t.Parallel()
|
||||
out, err := printFlagSchemaFor("+chart-create")("properties")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(out, &schema); err != nil {
|
||||
t.Fatalf("output not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if schema["type"] != "object" {
|
||||
t.Errorf("schema.type = %v, want object", schema["type"])
|
||||
}
|
||||
if _, ok := schema["properties"]; !ok {
|
||||
t.Errorf("schema missing nested .properties: keys=%v", keysOf(schema))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_UnknownFlagListsAvailable confirms the error
|
||||
// message tells the caller which flags exist for the shortcut.
|
||||
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown flag, got nil")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
|
||||
t.Errorf("error should mention shortcut + available flags; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintFlagSchema_UnknownShortcut surfaces a missing shortcut entry.
|
||||
func TestPrintFlagSchema_UnknownShortcut(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := printFlagSchemaFor("+not-a-real-shortcut")("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown shortcut")
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcuts_AttachesPrintFlagSchema confirms the registration loop
|
||||
// in Shortcuts() wires PrintFlagSchema onto each shortcut whose command
|
||||
// has a schema entry, and leaves it nil for shortcuts that don't.
|
||||
func TestShortcuts_AttachesPrintFlagSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
all := Shortcuts()
|
||||
withSchema := commandsWithFlagSchema()
|
||||
for _, s := range all {
|
||||
_, expected := withSchema[s.Command]
|
||||
got := s.PrintFlagSchema != nil
|
||||
if got != expected {
|
||||
t.Errorf("%s: PrintFlagSchema attached=%v, expected=%v", s.Command, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSchema_SystemFlagShortCircuit verifies the framework's
|
||||
// --print-schema interception: required flags are relaxed, Validate /
|
||||
// Execute are skipped, and the schema JSON appears on stdout.
|
||||
func TestPrintSchema_SystemFlagShortCircuit(t *testing.T) {
|
||||
t.Parallel()
|
||||
// +cells-set has required --range / --cells / --sheet-id; without
|
||||
// --print-schema, cobra would reject the call. With --print-schema,
|
||||
// it should print the schema and exit cleanly. The PrintFlagSchema
|
||||
// closure is normally attached by Shortcuts(), so we attach it here
|
||||
// to mirror that registration path.
|
||||
sc := CellsSet
|
||||
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
|
||||
stdout, err := runShortcut(t, sc, []string{"--print-schema", "--flag-name", "cells"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v\nstdout=%s", err, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "\"type\"") {
|
||||
t.Errorf("expected JSON Schema with \"type\" key; got=%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSchema_ListingWhenNoFlagNameGiven exercises the discovery
|
||||
// path: `--print-schema` without `--flag-name` should list the
|
||||
// shortcut's introspectable flags as JSON on stdout.
|
||||
func TestPrintSchema_ListingWhenNoFlagNameGiven(t *testing.T) {
|
||||
t.Parallel()
|
||||
sc := CellsSet
|
||||
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
|
||||
stdout, err := runShortcut(t, sc, []string{"--print-schema"})
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v\nstdout=%s", err, stdout)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stdout), &got); err != nil {
|
||||
t.Fatalf("stdout not JSON: %v\n%s", err, stdout)
|
||||
}
|
||||
flags, _ := got["introspectable_flags"].([]interface{})
|
||||
if len(flags) == 0 {
|
||||
t.Errorf("introspectable_flags empty: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut ensures we don't
|
||||
// inject --print-schema onto shortcuts that have no composite flags.
|
||||
// +workbook-info is read-only and not in the schema map.
|
||||
func TestPrintSchema_SystemFlagAbsentForReadOnlyShortcut(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, WorkbookInfo, []string{"--url", testURL, "--print-schema"})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Errorf("expected 'unknown flag'; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintSchema_UnknownFlagNameIsStructured pins issue #6: an unregistered
|
||||
// --flag-name passed to --print-schema must surface as a structured
|
||||
// *output.ExitError (type print_schema_error), not a bare error string, so the
|
||||
// agent-facing introspection path stays machine-parseable.
|
||||
func TestPrintSchema_UnknownFlagNameIsStructured(t *testing.T) {
|
||||
t.Parallel()
|
||||
// PrintFlagSchema is wired during registration (shortcuts.go), not on the
|
||||
// literal, so replicate that here to make Mount inject the --print-schema /
|
||||
// --flag-name system flags.
|
||||
sc := CellsSet
|
||||
sc.PrintFlagSchema = printFlagSchemaFor(sc.Command)
|
||||
_, _, err := runShortcutCapturingErr(t, sc, []string{
|
||||
"--print-schema", "--flag-name", "nonexistent",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for --print-schema with an unregistered flag name")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want a structured *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "print_schema_error" {
|
||||
t.Errorf("error detail = %+v, want type print_schema_error", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
500
shortcuts/sheets/flag_schema_validate.go
Normal file
500
shortcuts/sheets/flag_schema_validate.go
Normal file
@@ -0,0 +1,500 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── schema-driven flag validation ────────────────────────────────────
|
||||
//
|
||||
// Composite JSON flags (--properties, --cells, --operations, …) carry
|
||||
// non-trivial payloads whose shape is already pinned by the embedded
|
||||
// data/flag-schemas.json (see flag_schema.go). Rather than hand-write
|
||||
// per-spec validators for type / enum / required / nested checks, every
|
||||
// such flag is run through validatePropertiesAgainstSchema after the
|
||||
// shortcut's enhance hook has filled in any flat-flag-derived fields
|
||||
// (schema describes the *final* tool input, not the raw --properties
|
||||
// JSON the user typed). Cross-field business rules that JSON Schema
|
||||
// can't express (e.g. sparkline-update requires sparkline_id per item)
|
||||
// continue to live in spec.validateUpdateInput.
|
||||
//
|
||||
// The rule set is a subset of ai-tools/.../validate-tool-params.ts —
|
||||
// type, enum, oneOf, required, nested properties, and array items.
|
||||
// additionalProperties is intentionally lenient: the embedded schema
|
||||
// is a sub-tree and may not be exhaustive, so rejecting unknown keys
|
||||
// would be more disruptive than valuable.
|
||||
|
||||
// validateParsedJSONFlag validates the just-parsed value of a single
|
||||
// JSON flag against its embedded schema, if one is registered for the
|
||||
// (command, flag) pair. Called from parseJSONFlag so every JSON flag
|
||||
// — sort-keys, options, border-styles, cells, operations, ranges, … —
|
||||
// is checked at the user-input boundary, in user-input shape.
|
||||
//
|
||||
// `properties` is intentionally skipped here: its schema describes the
|
||||
// *final* tool-input properties (the shape after enhance* hooks
|
||||
// inject flat-flag-derived fields such as cond-format's rule_type),
|
||||
// not what the user typed under --properties. The input-builder tail
|
||||
// validates that one via validateInputAgainstSchema after enhance.
|
||||
func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
if fv == nil || value == nil {
|
||||
return nil
|
||||
}
|
||||
if _, skip := parseJSONFlagSkip[name]; skip {
|
||||
return nil
|
||||
}
|
||||
return validateValueAgainstSchema(fv, name, value)
|
||||
}
|
||||
|
||||
// parseJSONFlagSkip lists flag names where parseJSONFlag-time schema
|
||||
// validation is intentionally bypassed:
|
||||
//
|
||||
// - properties: schema describes the *final* tool-input shape (after
|
||||
// enhance hooks inject flat-flag-derived fields); validated at the
|
||||
// input-builder tail via validateInputAgainstSchema instead.
|
||||
// - operations: +batch-update's translator does richer validation
|
||||
// (allowed-shortcut allow-list, fan-out rejection, …) with more
|
||||
// actionable error messages than a generic "not in enum [...]"
|
||||
// would. The translator path stays the source of truth.
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
// pipeline shared by both validateParsedJSONFlag (user shape) and
|
||||
// validateInputAgainstSchema (wire shape).
|
||||
func validateValueAgainstSchema(fv flagView, name string, value interface{}) error {
|
||||
command := fv.Command()
|
||||
if command == "" {
|
||||
return nil
|
||||
}
|
||||
// Fast path: commands without a registered schema can't fail this check,
|
||||
// so skip the 256KB flag-schemas.json parse entirely for them.
|
||||
if _, ok := commandsWithSchema[command]; !ok {
|
||||
return nil
|
||||
}
|
||||
idx, _ := loadFlagSchemas()
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
raw, ok := entry[name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
return common.FlagErrorf("--%s: %s", name, vErr.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateInputAgainstSchema validates input[flag] for every flag the
|
||||
// embedded schema registers under the view's shortcut command. Returns
|
||||
// nil when no schema is registered for the command, or when none of
|
||||
// the registered flag names appear in `input` (schema describes the
|
||||
// shape of values when they are present, not which flags must be
|
||||
// present). Designed to be called at the tail of every input builder
|
||||
// so wiring up a new shortcut requires only the standard one-line
|
||||
// invocation, not a per-shortcut validator.
|
||||
func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error {
|
||||
if fv == nil || input == nil {
|
||||
return nil
|
||||
}
|
||||
command := fv.Command()
|
||||
if command == "" {
|
||||
return nil
|
||||
}
|
||||
// Fast path: commands without a registered schema have nothing to
|
||||
// validate, so skip the 256KB flag-schemas.json parse entirely.
|
||||
if _, ok := commandsWithSchema[command]; !ok {
|
||||
return nil
|
||||
}
|
||||
idx, _ := loadFlagSchemas()
|
||||
if idx == nil {
|
||||
return nil
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deterministic order so error messages are stable across runs.
|
||||
flagNames := make([]string, 0, len(entry))
|
||||
for name := range entry {
|
||||
flagNames = append(flagNames, name)
|
||||
}
|
||||
sort.Strings(flagNames)
|
||||
|
||||
for _, flagName := range flagNames {
|
||||
if _, skip := inputSchemaSkip[flagName]; skip {
|
||||
continue
|
||||
}
|
||||
// Input keys are wire-style (underscore); schema keys are CLI-style
|
||||
// (hyphen) — translate before lookup. Flags whose wire form lives
|
||||
// under a different key (e.g. --sort-keys → sort_conditions) won't
|
||||
// be found here; they're already validated in user shape via
|
||||
// parseJSONFlag → validateParsedJSONFlag.
|
||||
inputKey := strings.ReplaceAll(flagName, "-", "_")
|
||||
value, present := input[inputKey]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if err := validateValueAgainstSchema(fv, flagName, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inputSchemaSkip mirrors parseJSONFlagSkip for the input-builder
|
||||
// tail. Same rationale: bypass schema validation for flags where
|
||||
// richer translator-side validation owns the contract (operations).
|
||||
var inputSchemaSkip = map[string]struct{}{
|
||||
"operations": {},
|
||||
}
|
||||
|
||||
// schemaProperty mirrors the JSON Schema subset used by
|
||||
// data/flag-schemas.json. Unknown keys (description, …) are dropped —
|
||||
// they're documentation.
|
||||
//
|
||||
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
||||
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
||||
// "no bound declared" from "bound is zero".
|
||||
//
|
||||
// AdditionalProperties handles the JSON Schema three-way:
|
||||
// - absent / true → lenient, any extra key allowed (validator's
|
||||
// default; matches the file header's "may not be exhaustive"
|
||||
// stance for schemas that simply don't declare it).
|
||||
// - false → strict, every extra key rejected.
|
||||
// - <schema> → extra keys allowed, but each value must validate
|
||||
// against this schema. Used today for pivot's dynamic
|
||||
// map<string, array<string>> fields (groups / collapse).
|
||||
type schemaProperty struct {
|
||||
Type string `json:"type"`
|
||||
Nullable bool `json:"nullable"`
|
||||
Enum []interface{} `json:"enum"`
|
||||
Properties map[string]*schemaProperty `json:"properties"`
|
||||
Required []string `json:"required"`
|
||||
Items *schemaProperty `json:"items"`
|
||||
OneOf []*schemaProperty `json:"oneOf"`
|
||||
Minimum *float64 `json:"minimum"`
|
||||
Maximum *float64 `json:"maximum"`
|
||||
MinItems *int `json:"minItems"`
|
||||
MaxItems *int `json:"maxItems"`
|
||||
AdditionalProperties *additionalProps `json:"additionalProperties"`
|
||||
}
|
||||
|
||||
// additionalProps captures the three JSON Schema forms of
|
||||
// `additionalProperties`. UnmarshalJSON decodes true / false / object
|
||||
// into the same struct so callers can branch on (Strict, Schema).
|
||||
type additionalProps struct {
|
||||
Strict bool // true when schema declared additionalProperties:false
|
||||
Schema *schemaProperty // non-nil when declared as an object schema
|
||||
}
|
||||
|
||||
func (a *additionalProps) UnmarshalJSON(data []byte) error {
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
switch trimmed {
|
||||
case "true":
|
||||
return nil // lenient — same as absent
|
||||
case "false":
|
||||
a.Strict = true
|
||||
return nil
|
||||
}
|
||||
var sub schemaProperty
|
||||
if err := json.Unmarshal(data, &sub); err != nil {
|
||||
return err
|
||||
}
|
||||
a.Schema = &sub
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAgainstSchema recursively checks `value` against `schema`,
|
||||
// prefixing any failure with the JSON path navigated so far.
|
||||
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
|
||||
if schema == nil {
|
||||
return nil // defensive — current callers always pass &schema, but
|
||||
// keeps validator safe for future programmatic construction.
|
||||
}
|
||||
if value == nil && schema.Nullable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if schema.Type != "" {
|
||||
if !matchesJSONType(value, schema.Type) {
|
||||
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric bounds — only checked when value is a number (type mismatch
|
||||
// already reported above). Apply to both `number` and `integer` types.
|
||||
if num, ok := value.(float64); ok {
|
||||
if schema.Minimum != nil && num < *schema.Minimum {
|
||||
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
|
||||
}
|
||||
if schema.Maximum != nil && num > *schema.Maximum {
|
||||
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
|
||||
}
|
||||
}
|
||||
|
||||
// Array length bounds — only checked when value is an array.
|
||||
if arr, ok := value.([]interface{}); ok {
|
||||
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
||||
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
|
||||
}
|
||||
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
||||
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
|
||||
}
|
||||
}
|
||||
|
||||
if len(schema.Enum) > 0 {
|
||||
matched := false
|
||||
for _, allowed := range schema.Enum {
|
||||
if jsonEqual(allowed, value) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
msg := fmt.Sprintf("%svalue %s is not in enum %s",
|
||||
pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum))
|
||||
if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
|
||||
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(schema.OneOf) > 0 {
|
||||
matched := false
|
||||
for _, sub := range schema.OneOf {
|
||||
if validateAgainstSchema(value, sub, path) == nil {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
|
||||
}
|
||||
}
|
||||
|
||||
// Object-level checks. `required` and `properties` are independent
|
||||
// per JSON Schema: `required` enforces keys regardless of whether
|
||||
// the schema also describes their per-key shape via `properties`.
|
||||
if obj, ok := value.(map[string]interface{}); ok {
|
||||
for _, key := range schema.Required {
|
||||
if _, present := obj[key]; !present {
|
||||
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
|
||||
}
|
||||
}
|
||||
if schema.Properties != nil {
|
||||
keys := make([]string, 0, len(schema.Properties))
|
||||
for k := range schema.Properties {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
sub := schema.Properties[key]
|
||||
v, present := obj[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
// Case-insensitive enum tolerance: when the value matches an
|
||||
// allowed enum entry except for casing, rewrite it in place to
|
||||
// the canonical spelling. The schema lists enums in their
|
||||
// canonical (lower-case) form, so "SUM" / "COUNTA" would
|
||||
// otherwise be rejected right here before the request is even
|
||||
// sent; normalizing kills the whole pivot summarize_by "SUM vs
|
||||
// sum" class. Genuinely-unknown values still fail below, with
|
||||
// their own did-you-mean hint.
|
||||
if sub != nil && len(sub.Enum) > 0 {
|
||||
if canon := suggestEnumMatch(v, sub.Enum); canon != "" {
|
||||
obj[key] = canon
|
||||
v = canon
|
||||
}
|
||||
}
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(v, sub, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// additionalProperties: enforce only when explicitly declared.
|
||||
// Absent means lenient (matches the file header's stance). Sort
|
||||
// extras so the first rejection is deterministic across runs.
|
||||
if schema.AdditionalProperties != nil {
|
||||
extras := make([]string, 0)
|
||||
for key := range obj {
|
||||
if _, declared := schema.Properties[key]; declared {
|
||||
continue
|
||||
}
|
||||
extras = append(extras, key)
|
||||
}
|
||||
sort.Strings(extras)
|
||||
for _, key := range extras {
|
||||
if schema.AdditionalProperties.Strict {
|
||||
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
||||
}
|
||||
if schema.AdditionalProperties.Schema != nil {
|
||||
child := key
|
||||
if path != "" {
|
||||
child = path + "." + key
|
||||
}
|
||||
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if schema.Type == "array" && schema.Items != nil {
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil // type mismatch already reported above.
|
||||
}
|
||||
for i, item := range arr {
|
||||
child := fmt.Sprintf("%s[%d]", path, i)
|
||||
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func matchesJSONType(value interface{}, expected string) bool {
|
||||
switch expected {
|
||||
case "object":
|
||||
_, ok := value.(map[string]interface{})
|
||||
return ok
|
||||
case "array":
|
||||
_, ok := value.([]interface{})
|
||||
return ok
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
case "number":
|
||||
_, ok := value.(float64)
|
||||
return ok
|
||||
case "integer":
|
||||
f, ok := value.(float64)
|
||||
return ok && f == float64(int64(f))
|
||||
case "boolean":
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
case "null":
|
||||
return value == nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func jsType(value interface{}) string {
|
||||
switch value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case map[string]interface{}:
|
||||
return "object"
|
||||
case []interface{}:
|
||||
return "array"
|
||||
case string:
|
||||
return "string"
|
||||
case float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
}
|
||||
return fmt.Sprintf("%T", value)
|
||||
}
|
||||
|
||||
func jsonEqual(a, b interface{}) bool {
|
||||
ja, _ := json.Marshal(a)
|
||||
jb, _ := json.Marshal(b)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// formatJSONValue is the "what you actually passed" half of an enum
|
||||
// error. Strings get JSON-quoted ("SUM"); everything else (numbers,
|
||||
// booleans, null, objects, arrays) gets its JSON encoding. Marshal
|
||||
// failure falls back to %v so we never panic just to format an error.
|
||||
func formatJSONValue(v interface{}) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// formatEnum renders the allowed-values list for an enum error. Caps
|
||||
// the visible entries at enumDisplayLimit so a 50-shortcut enum
|
||||
// doesn't bury the actual error in a wall of options; the overflow
|
||||
// hint tells the user how many more exist (and to consult --help /
|
||||
// --print-schema for the full list).
|
||||
const enumDisplayLimit = 8
|
||||
|
||||
func formatEnum(values []interface{}) string {
|
||||
if len(values) <= enumDisplayLimit {
|
||||
return "[" + joinFormatted(values) + "]"
|
||||
}
|
||||
shown := values[:enumDisplayLimit]
|
||||
return fmt.Sprintf("[%s, … (%d more)]", joinFormatted(shown), len(values)-enumDisplayLimit)
|
||||
}
|
||||
|
||||
func joinFormatted(values []interface{}) string {
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, v := range values {
|
||||
parts = append(parts, formatJSONValue(v))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// suggestEnumMatch returns a "did you mean" candidate when the user's
|
||||
// value differs from an allowed enum entry only in casing — the most
|
||||
// common real-world mistake ("SUM" vs "sum", "True" vs "true"). The
|
||||
// match is restricted to strings; non-string enums (numbers, etc.)
|
||||
// don't have a casing notion. Returns "" when no near-miss exists.
|
||||
func suggestEnumMatch(value interface{}, values []interface{}) string {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
for _, v := range values {
|
||||
if vs, ok := v.(string); ok && strings.ToLower(vs) == lower {
|
||||
if vs != s { // skip exact-equal (already would have matched).
|
||||
return vs
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pathPrefix(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return path + ": "
|
||||
}
|
||||
|
||||
func pathOrRoot(path string) string {
|
||||
if path == "" {
|
||||
return "(root)"
|
||||
}
|
||||
return path
|
||||
}
|
||||
589
shortcuts/sheets/flag_schema_validate_test.go
Normal file
589
shortcuts/sheets/flag_schema_validate_test.go
Normal file
@@ -0,0 +1,589 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parseSchema is a tiny test helper: take an inline JSON Schema string,
|
||||
// hand back a *schemaProperty for validateAgainstSchema. Lets test
|
||||
// cases declare their schema inline rather than hand-building structs.
|
||||
func parseSchema(t *testing.T, raw string) *schemaProperty {
|
||||
t.Helper()
|
||||
var s schemaProperty
|
||||
if err := json.Unmarshal([]byte(raw), &s); err != nil {
|
||||
t.Fatalf("bad inline schema %q: %v", raw, err)
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// parseValue decodes a JSON literal the same way encoding/json gives
|
||||
// validateAgainstSchema its input (numbers → float64, objects →
|
||||
// map[string]interface{}, arrays → []interface{}).
|
||||
func parseValue(t *testing.T, raw string) interface{} {
|
||||
t.Helper()
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
t.Fatalf("bad inline value %q: %v", raw, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_EnumCaseNormalization pins the case-insensitive
|
||||
// enum tolerance: a value matching an allowed enum entry except for casing is
|
||||
// rewritten in place to the canonical spelling (so the case-sensitive backend
|
||||
// accepts it), while genuinely-unknown values still fail. Only fires for enum
|
||||
// fields nested in an object/array — the pivot values[].summarize_by path.
|
||||
func TestValidateAgainstSchema_EnumCaseNormalization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := parseSchema(t, `{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count","average"]}}}`)
|
||||
|
||||
t.Run("rewrites case-only mismatch in place", func(t *testing.T) {
|
||||
obj := map[string]interface{}{"summarize_by": "SUM"}
|
||||
if err := validateAgainstSchema(obj, schema, ""); err != nil {
|
||||
t.Fatalf("case-only value should pass after normalization, got: %v", err)
|
||||
}
|
||||
if got := obj["summarize_by"]; got != "sum" {
|
||||
t.Errorf("summarize_by = %q, want normalized %q", got, "sum")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaves exact match untouched", func(t *testing.T) {
|
||||
obj := map[string]interface{}{"summarize_by": "count"}
|
||||
if err := validateAgainstSchema(obj, schema, ""); err != nil {
|
||||
t.Fatalf("exact match should pass: %v", err)
|
||||
}
|
||||
if got := obj["summarize_by"]; got != "count" {
|
||||
t.Errorf("exact value mutated to %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown value still fails", func(t *testing.T) {
|
||||
obj := map[string]interface{}{"summarize_by": "COUNTA"}
|
||||
if err := validateAgainstSchema(obj, schema, ""); err == nil {
|
||||
t.Fatal("unknown enum value should fail")
|
||||
} else if !strings.Contains(err.Error(), "not in enum") {
|
||||
t.Errorf("want enum error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalizes inside array-of-objects (values[] shape)", func(t *testing.T) {
|
||||
arrSchema := parseSchema(t, `{"type":"array","items":{"type":"object","properties":{"summarize_by":{"type":"string","enum":["sum","count"]}}}}`)
|
||||
arr := []interface{}{
|
||||
map[string]interface{}{"summarize_by": "Sum"},
|
||||
map[string]interface{}{"summarize_by": "COUNT"},
|
||||
}
|
||||
if err := validateAgainstSchema(arr, arrSchema, ""); err != nil {
|
||||
t.Fatalf("array case normalization failed: %v", err)
|
||||
}
|
||||
if got := arr[0].(map[string]interface{})["summarize_by"]; got != "sum" {
|
||||
t.Errorf("arr[0] summarize_by = %q, want sum", got)
|
||||
}
|
||||
if got := arr[1].(map[string]interface{})["summarize_by"]; got != "count" {
|
||||
t.Errorf("arr[1] summarize_by = %q, want count", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema is the validator's contract test: every
|
||||
// supported keyword (type, enum, oneOf, required, nested properties,
|
||||
// array items, nullable, minimum/maximum, minItems/maxItems) gets a
|
||||
// pass + fail case, and the failure message is asserted to mention
|
||||
// the JSON path and the violated constraint. Together these pin the
|
||||
// validator's behaviour without going through any shortcut wiring.
|
||||
func TestValidateAgainstSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
schema string
|
||||
value string
|
||||
wantOK bool
|
||||
wantInErr string // substring required in error message when !wantOK
|
||||
}{
|
||||
// ─── type ─────────────────────────────────────────────────────
|
||||
{"type string ok", `{"type":"string"}`, `"hi"`, true, ""},
|
||||
{"type string wrong", `{"type":"string"}`, `42`, false, `expected type "string"`},
|
||||
{"type number ok", `{"type":"number"}`, `3.14`, true, ""},
|
||||
{"type number wrong", `{"type":"number"}`, `"x"`, false, `got "string"`},
|
||||
{"type integer ok", `{"type":"integer"}`, `5`, true, ""},
|
||||
{"type integer fractional rejected", `{"type":"integer"}`, `5.5`, false, `expected type "integer"`},
|
||||
{"type boolean ok", `{"type":"boolean"}`, `true`, true, ""},
|
||||
{"type array ok", `{"type":"array"}`, `[1,2]`, true, ""},
|
||||
{"type object ok", `{"type":"object"}`, `{"a":1}`, true, ""},
|
||||
|
||||
// ─── nullable short-circuit ───────────────────────────────────
|
||||
{"nullable null accepted", `{"type":"string","nullable":true}`, `null`, true, ""},
|
||||
{"nullable schema still type-checks non-null", `{"type":"string","nullable":true}`, `42`, false, `expected type "string"`},
|
||||
{"nullable schema accepts matching type", `{"type":"string","nullable":true}`, `"x"`, true, ""},
|
||||
{"null rejected when nullable not set", `{"type":"string"}`, `null`, false, `expected type "string"`},
|
||||
|
||||
// ─── enum ────────────────────────────────────────────────────
|
||||
{"enum hit", `{"type":"string","enum":["asc","desc"]}`, `"asc"`, true, ""},
|
||||
{"enum miss", `{"type":"string","enum":["asc","desc"]}`, `"sideways"`, false, `not in enum ["asc", "desc"]`},
|
||||
|
||||
// ─── oneOf ───────────────────────────────────────────────────
|
||||
{"oneOf string branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `"x"`, true, ""},
|
||||
{"oneOf number branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `7`, true, ""},
|
||||
{"oneOf no branch", `{"oneOf":[{"type":"string"},{"type":"number"}]}`, `true`, false, `oneOf alternatives`},
|
||||
|
||||
// ─── required ────────────────────────────────────────────────
|
||||
{
|
||||
"required key present",
|
||||
`{"type":"object","required":["a"],"properties":{"a":{"type":"string"}}}`,
|
||||
`{"a":"x"}`, true, "",
|
||||
},
|
||||
{
|
||||
"required key missing",
|
||||
`{"type":"object","required":["a"]}`,
|
||||
`{}`, false, `required property "a"`,
|
||||
},
|
||||
|
||||
// ─── nested properties recurse ───────────────────────────────
|
||||
{
|
||||
"nested property wrong type",
|
||||
`{"type":"object","properties":{"inner":{"type":"object","properties":{"x":{"type":"number"}}}}}`,
|
||||
`{"inner":{"x":"oops"}}`, false, `inner.x: expected type "number"`,
|
||||
},
|
||||
|
||||
// ─── array items recurse with [i] path ───────────────────────
|
||||
{
|
||||
"array items ok",
|
||||
`{"type":"array","items":{"type":"string"}}`,
|
||||
`["a","b"]`, true, "",
|
||||
},
|
||||
{
|
||||
"array item wrong type pinpoints index",
|
||||
`{"type":"array","items":{"type":"string"}}`,
|
||||
`["a",2,"c"]`, false, `[1]: expected type "string"`,
|
||||
},
|
||||
|
||||
// ─── numeric bounds (P0 additions) ───────────────────────────
|
||||
{"minimum ok", `{"type":"number","minimum":0}`, `0`, true, ""},
|
||||
{"minimum fail", `{"type":"number","minimum":0}`, `-1`, false, `below minimum`},
|
||||
{"maximum ok", `{"type":"number","maximum":100}`, `100`, true, ""},
|
||||
{"maximum fail", `{"type":"number","maximum":100}`, `101`, false, `above maximum`},
|
||||
{"minimum on integer", `{"type":"integer","minimum":10}`, `5`, false, `below minimum`},
|
||||
|
||||
// ─── array length bounds (P0 additions) ──────────────────────
|
||||
{"minItems ok", `{"type":"array","minItems":1}`, `[1]`, true, ""},
|
||||
{"minItems fail", `{"type":"array","minItems":1}`, `[]`, false, `array has 0 items, minimum is 1`},
|
||||
{"maxItems ok", `{"type":"array","maxItems":3}`, `[1,2,3]`, true, ""},
|
||||
{"maxItems fail", `{"type":"array","maxItems":3}`, `[1,2,3,4]`, false, `array has 4 items, maximum is 3`},
|
||||
|
||||
// ─── combined bounds inside nested array of objects ──────────
|
||||
{
|
||||
"nested minimum in array item objects",
|
||||
`{"type":"array","items":{"type":"object","properties":{"row":{"type":"integer","minimum":0}}}}`,
|
||||
`[{"row":0},{"row":-1}]`, false, `[1].row: value -1 is below minimum 0`,
|
||||
},
|
||||
|
||||
// ─── additionalProperties absent: lenient (default) ──────────
|
||||
{
|
||||
"extras allowed when additionalProperties absent",
|
||||
`{"type":"object","properties":{"a":{"type":"string"}}}`,
|
||||
`{"a":"x","whatever":42}`, true, "",
|
||||
},
|
||||
|
||||
// ─── additionalProperties:false: strict mode ─────────────────
|
||||
{
|
||||
"extras allowed when additionalProperties:true (explicit)",
|
||||
`{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":true}`,
|
||||
`{"a":"x","extra":1}`, true, "",
|
||||
},
|
||||
{
|
||||
"extras rejected when additionalProperties:false",
|
||||
`{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`,
|
||||
`{"a":"x","typo":1}`, false, `unexpected property "typo"`,
|
||||
},
|
||||
{
|
||||
"declared property still accepted under strict mode",
|
||||
`{"type":"object","properties":{"a":{"type":"string"}},"additionalProperties":false}`,
|
||||
`{"a":"x"}`, true, "",
|
||||
},
|
||||
|
||||
// ─── additionalProperties:<schema>: extras must match ────────
|
||||
{
|
||||
"extras pass when matching additionalProperties schema",
|
||||
`{"type":"object","properties":{"name":{"type":"string"}},"additionalProperties":{"type":"array","items":{"type":"string"}}}`,
|
||||
`{"name":"x","g1":["a","b"],"g2":["c"]}`, true, "",
|
||||
},
|
||||
{
|
||||
"extras fail when wrong type for additionalProperties schema",
|
||||
`{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}`,
|
||||
`{"g1":[1,2]}`, false, `g1[0]: expected type "string"`,
|
||||
},
|
||||
{
|
||||
"extras fail when value isn't even right kind",
|
||||
`{"type":"object","additionalProperties":{"type":"array"}}`,
|
||||
`{"key":"not-an-array"}`, false, `key: expected type "array"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := parseSchema(t, tc.schema)
|
||||
v := parseValue(t, tc.value)
|
||||
err := validateAgainstSchema(v, s, "")
|
||||
if tc.wantOK {
|
||||
if err != nil {
|
||||
t.Fatalf("expected pass, got error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got pass", tc.wantInErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantInErr) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantInErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_EnumErrorEnhancements pins the three
|
||||
// enum-error UX upgrades together:
|
||||
// - the failing value is quoted in JSON form ("SUM", not bare SUM)
|
||||
// - the allowed list is JSON-quoted ("sum", not bare sum) and gets
|
||||
// truncated past 8 entries with an "N more" hint
|
||||
// - case-only mismatches surface a `did you mean` suggestion
|
||||
// pointing at the canonical spelling
|
||||
func TestValidateAgainstSchema_EnumErrorEnhancements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("small enum is fully listed and quoted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := parseSchema(t, `{"type":"string","enum":["asc","desc"]}`)
|
||||
err := validateAgainstSchema("sideways", s, "order")
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, `value "sideways"`) {
|
||||
t.Errorf("want failing value quoted; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `["asc", "desc"]`) {
|
||||
t.Errorf("want enum list comma+quote formatted; got %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("large enum is truncated with overflow hint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 12 values; default enumDisplayLimit is 8.
|
||||
s := parseSchema(t, `{"type":"string","enum":[
|
||||
"a","b","c","d","e","f","g","h","i","j","k","l"
|
||||
]}`)
|
||||
err := validateAgainstSchema("z", s, "x")
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "4 more") {
|
||||
t.Errorf("want overflow hint '4 more'; got %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, `"i"`) || strings.Contains(msg, `"l"`) {
|
||||
t.Errorf("want truncation to first 8; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, `"h"`) { // 8th entry should be present.
|
||||
t.Errorf("want first 8 entries shown; got %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-only mismatch produces did-you-mean hint", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := parseSchema(t, `{"type":"string","enum":["sum","count","average"]}`)
|
||||
err := validateAgainstSchema("SUM", s, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `did you mean "sum"?`) {
|
||||
t.Errorf("want did-you-mean hint; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no did-you-mean when value is not a near miss", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := parseSchema(t, `{"type":"string","enum":["sum","count"]}`)
|
||||
err := validateAgainstSchema("BOGUS", s, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation")
|
||||
}
|
||||
if strings.Contains(err.Error(), "did you mean") {
|
||||
t.Errorf("want no hint for unrelated value; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("did-you-mean only triggers for strings (not numbers)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := parseSchema(t, `{"enum":[1,2,3]}`)
|
||||
err := validateAgainstSchema(float64(4), s, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation")
|
||||
}
|
||||
if strings.Contains(err.Error(), "did you mean") {
|
||||
t.Errorf("numeric enum should not get casing hint; got %q", err.Error())
|
||||
}
|
||||
// And the failing numeric value still surfaces in JSON form.
|
||||
if !strings.Contains(err.Error(), "value 4 ") {
|
||||
t.Errorf("want numeric value in error; got %q", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_RealEnumCaseNormalized confirms the
|
||||
// case-insensitive enum tolerance fires against the real embedded schema for
|
||||
// the most common real-world miscue — pivot summarize_by upper-cased. "SUM" is
|
||||
// rewritten to "sum" in place and the input passes; previously this surfaced a
|
||||
// did-you-mean error, but in-place canonicalization fixes it so the agent's first try wins.
|
||||
func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+pivot-create"}
|
||||
in := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"values": []interface{}{
|
||||
map[string]interface{}{"field": "A", "summarize_by": "SUM"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := validateInputAgainstSchema(fv, in); err != nil {
|
||||
t.Fatalf("upper-case summarize_by should be normalized and pass, got: %v", err)
|
||||
}
|
||||
vals := in["properties"].(map[string]interface{})["values"].([]interface{})
|
||||
if got := vals[0].(map[string]interface{})["summarize_by"]; got != "sum" {
|
||||
t.Errorf("summarize_by = %q, want normalized to %q", got, "sum")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_NilSchemaSafe pins the defensive
|
||||
// `if schema == nil { return nil }` guard. Current production callers
|
||||
// always hand validator a real schema, but the guard means future
|
||||
// programmatic construction (or a malformed schema sub-tree decoded
|
||||
// as a nil pointer inside oneOf) won't crash with a nil deref.
|
||||
func TestValidateAgainstSchema_NilSchemaSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := validateAgainstSchema("anything", nil, ""); err != nil {
|
||||
t.Errorf("nil schema should noop; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure
|
||||
// asserts that when multiple extras violate additionalProperties:false,
|
||||
// the *alphabetically first* extra is the one reported — without the
|
||||
// sort, Go map iteration would make the failing key non-deterministic
|
||||
// across runs and the error message would flake.
|
||||
func TestValidateAgainstSchema_AdditionalPropertiesSortedFirstFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{"declared":{"type":"string"}},
|
||||
"additionalProperties":false
|
||||
}`)
|
||||
// Three extras; "alpha" comes first when sorted.
|
||||
value := parseValue(t, `{"declared":"ok","zeta":1,"alpha":2,"middle":3}`)
|
||||
for i := 0; i < 30; i++ {
|
||||
err := validateAgainstSchema(value, schema, "")
|
||||
if err == nil {
|
||||
t.Fatalf("iter %d: expected extras to be rejected", i)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"alpha"`) {
|
||||
t.Fatalf("iter %d: expected alphabetically first extra to be reported; got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_ArrayItemRequired pins that `required`
|
||||
// fires inside array items too — the recursion path applies the same
|
||||
// object-level rules at every level, so a missing key in items
|
||||
// surfaces as `[i].missing` and not a silently-passed item.
|
||||
func TestValidateAgainstSchema_ArrayItemRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"array",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"required":["id"],
|
||||
"properties":{"id":{"type":"string"}}
|
||||
}
|
||||
}`)
|
||||
value := parseValue(t, `[{"id":"a"},{"name":"b"}]`)
|
||||
err := validateAgainstSchema(value, schema, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected required violation on items[1]")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `required property "id"`) || !strings.Contains(err.Error(), "[1]") {
|
||||
t.Errorf("expected required-id at [1]; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstSchema_DeterministicPropertyOrder regresses the
|
||||
// "iterate properties in sorted key order" guarantee so that the
|
||||
// first-failure error message is stable across runs (Go map iteration
|
||||
// is randomized — without the sort, a schema with two bad fields
|
||||
// would non-deterministically report either one).
|
||||
func TestValidateAgainstSchema_DeterministicPropertyOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := parseSchema(t, `{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"a":{"type":"string"},
|
||||
"b":{"type":"string"},
|
||||
"c":{"type":"string"}
|
||||
}
|
||||
}`)
|
||||
value := parseValue(t, `{"a":1,"b":2,"c":3}`)
|
||||
// Run many times; "a" must always be the reported field (sorted first).
|
||||
for i := 0; i < 50; i++ {
|
||||
err := validateAgainstSchema(value, schema, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "a:") {
|
||||
t.Fatalf("iter %d: expected error mentioning 'a:'; got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_RealSchema exercises the full
|
||||
// (command, flag) lookup pipeline against the real embedded
|
||||
// flag-schemas.json — confirms that an out-of-enum summarize_by
|
||||
// surfaces a descriptive error all the way through, and that a
|
||||
// well-formed input passes. Mirrors what shortcut tests check, but
|
||||
// without booting cobra.
|
||||
func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+pivot-create"}
|
||||
|
||||
// Schema-conformant: values[0].summarize_by="sum" is in enum.
|
||||
good := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"values": []interface{}{
|
||||
map[string]interface{}{"field": "A", "summarize_by": "sum"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := validateInputAgainstSchema(fv, good); err != nil {
|
||||
t.Errorf("good input rejected: %v", err)
|
||||
}
|
||||
|
||||
// Schema-violating: a value with no case-only match still fails loudly
|
||||
// (case normalization only rescues casing mistakes, not unknown words).
|
||||
bad := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"values": []interface{}{
|
||||
map[string]interface{}{"field": "A", "summarize_by": "bogus"},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
|
||||
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_RealMinItems exercises a P0
|
||||
// addition end-to-end: +pivot-create properties.values has
|
||||
// minItems:1, so an explicit empty values array is rejected by the
|
||||
// schema validator (previously slipped past).
|
||||
func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+pivot-create"}
|
||||
bad := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"values": []interface{}{}, // minItems:1 violated
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
if err == nil {
|
||||
t.Fatal("expected minItems violation for empty values, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
|
||||
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_RealMinimum exercises another P0
|
||||
// addition: +chart-create properties.position.row has minimum:0, so
|
||||
// row:-1 must be rejected before the request hits the wire.
|
||||
func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+chart-create"}
|
||||
bad := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"position": map[string]interface{}{"row": float64(-1), "col": "A"},
|
||||
"size": map[string]interface{}{"width": float64(400), "height": float64(300)},
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
if err == nil {
|
||||
t.Fatal("expected minimum violation for row:-1, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
|
||||
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_RealAdditionalProperties pins the
|
||||
// additionalProperties: <schema> form against the real embedded
|
||||
// schema. +pivot-create properties.collapse is declared as a dynamic
|
||||
// map<field-name, array<string>>; passing a non-string in any value
|
||||
// must be rejected end-to-end.
|
||||
func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+pivot-create"}
|
||||
|
||||
good := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}},
|
||||
"collapse": map[string]interface{}{"region": []interface{}{"NA", "EU"}},
|
||||
},
|
||||
}
|
||||
if err := validateInputAgainstSchema(fv, good); err != nil {
|
||||
t.Errorf("schema-conformant collapse rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"values": []interface{}{map[string]interface{}{"field": "A", "summarize_by": "sum"}},
|
||||
"collapse": map[string]interface{}{"region": []interface{}{"NA", 42}}, // 42 violates items.type=string
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
if err == nil {
|
||||
t.Fatal("expected additionalProperties violation, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
|
||||
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_UnknownCommand returns nil — schema
|
||||
// validation is opportunistic, an unknown command never errors. Lets
|
||||
// shortcuts opt out simply by not registering a schema entry.
|
||||
func TestValidateInputAgainstSchema_UnknownCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+definitely-not-a-shortcut"}
|
||||
if err := validateInputAgainstSchema(fv, map[string]interface{}{"properties": "anything"}); err != nil {
|
||||
t.Errorf("unknown command should noop; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInputAgainstSchema_SkipOperations confirms that the
|
||||
// operations skip-list entry is honoured: even with a clearly
|
||||
// malformed operations value, validateInputAgainstSchema is a no-op
|
||||
// because translator-side validation owns that contract.
|
||||
func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+batch-update"}
|
||||
input := map[string]interface{}{
|
||||
"operations": "definitely-not-an-array",
|
||||
}
|
||||
if err := validateInputAgainstSchema(fv, input); err != nil {
|
||||
t.Errorf("operations should be skipped; got %v", err)
|
||||
}
|
||||
}
|
||||
35
shortcuts/sheets/flag_schemas_gen.go
Normal file
35
shortcuts/sheets/flag_schemas_gen.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-schemas.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// commandsWithSchema is the set of shortcut commands that have at least one
|
||||
// introspectable composite flag in data/flag-schemas.json. Codegen'd so the
|
||||
// registration loop (shortcuts.go) and the validate fast-path can gate on it
|
||||
// without parsing the 256KB schema blob at startup (that parse used to run on
|
||||
// every CLI invocation, sheets or not). The 256KB is now only unmarshaled
|
||||
// on --print-schema or when validating a command that is in this set. Do not
|
||||
// hand-edit; regenerate with `go generate ./shortcuts/sheets/...`.
|
||||
var commandsWithSchema = map[string]struct{}{
|
||||
"+batch-update": {},
|
||||
"+cells-batch-set-style": {},
|
||||
"+cells-set": {},
|
||||
"+cells-set-style": {},
|
||||
"+chart-create": {},
|
||||
"+chart-update": {},
|
||||
"+cond-format-create": {},
|
||||
"+cond-format-update": {},
|
||||
"+dropdown-set": {},
|
||||
"+dropdown-update": {},
|
||||
"+filter-create": {},
|
||||
"+filter-update": {},
|
||||
"+filter-view-create": {},
|
||||
"+filter-view-update": {},
|
||||
"+pivot-create": {},
|
||||
"+pivot-update": {},
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
}
|
||||
23
shortcuts/sheets/flag_schemas_gen_test.go
Normal file
23
shortcuts/sheets/flag_schemas_gen_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCommandsWithSchemaGen_MatchesJSON guards against drift between the
|
||||
// codegen'd commandsWithSchema set (flag_schemas_gen.go) and the actual keys
|
||||
// in data/flag-schemas.json — commandsWithFlagSchema() derives the set by
|
||||
// parsing the embedded blob. This equivalence is what lets registration and
|
||||
// the validate fast-path gate on the cheap set instead of parsing the 256KB
|
||||
// schema at startup.
|
||||
func TestCommandsWithSchemaGen_MatchesJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
fromJSON := commandsWithFlagSchema()
|
||||
if !reflect.DeepEqual(fromJSON, commandsWithSchema) {
|
||||
t.Error("commandsWithSchema differs from data/flag-schemas.json; regenerate flag_schemas_gen.go")
|
||||
}
|
||||
}
|
||||
321
shortcuts/sheets/flag_view.go
Normal file
321
shortcuts/sheets/flag_view.go
Normal file
@@ -0,0 +1,321 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// flagView is the read-only flag-accessor surface that every CLI-shape →
|
||||
// MCP-tool-body translator (the *Input builders) depends on. It is satisfied
|
||||
// as-is by *common.RuntimeContext (cobra-backed, used by standalone shortcut
|
||||
// execution) and by mapFlagView (map-backed, used by +batch-update sub-ops).
|
||||
//
|
||||
// Routing both paths through the same interface lets a sub-op inside
|
||||
// +batch-update reuse the exact same translator the standalone shortcut runs,
|
||||
// so the generated MCP body is identical either way (enforced by the
|
||||
// batch-vs-standalone contract test).
|
||||
type flagView interface {
|
||||
Str(name string) string
|
||||
Int(name string) int
|
||||
Float64(name string) float64
|
||||
Bool(name string) bool
|
||||
StrArray(name string) []string
|
||||
StrSlice(name string) []string
|
||||
Changed(name string) bool
|
||||
// Command returns the shortcut command this view feeds (e.g.
|
||||
// "+pivot-create"). Used to look up the schema entry for
|
||||
// schema-driven flag validation; both standalone and batch sub-op
|
||||
// paths populate it so a sub-op gets validated against the same
|
||||
// schema as the standalone shortcut.
|
||||
Command() string
|
||||
}
|
||||
|
||||
// mapFlagView adapts a +batch-update sub-op input object (decoded JSON) to the
|
||||
// flagView interface so the standalone *Input translators can consume it.
|
||||
//
|
||||
// Keys are matched leniently against the CLI flag name: a translator asking for
|
||||
// "source-range" finds either "source-range" or "source_range" in the map (the
|
||||
// reference docs use CLI flag names; users frequently send the underscore
|
||||
// form). Composite values (arrays / objects for flags like cells / properties /
|
||||
// sort-keys) are re-encoded to a JSON string on Str() so the downstream
|
||||
// parseJSONFlag round-trips them exactly as it would a CLI string argument.
|
||||
//
|
||||
// To mirror the standalone cobra layer exactly, value reads fall back to the
|
||||
// flag's declared default (seeded from flag-defs.json), while Changed() reflects
|
||||
// only what the user actually provided. This split matters because some
|
||||
// translators branch on Changed() (e.g. omit target_index unless --index was
|
||||
// set) and others read defaulted values (e.g. row-count defaults to 200).
|
||||
type mapFlagView struct {
|
||||
raw map[string]interface{} // user-supplied sub-op input (drives Changed)
|
||||
defaults map[string]interface{} // flag defaults (value fallback only)
|
||||
command string // shortcut command (e.g. "+chart-create"); used by schema validator
|
||||
}
|
||||
|
||||
func (m mapFlagView) Command() string { return m.command }
|
||||
|
||||
// newMapFlagViewForCommand wraps a sub-op input and seeds the value-fallback
|
||||
// defaults declared for `command` in flag-defs.json, so an absent flag resolves
|
||||
// to the same value the standalone cobra command would carry.
|
||||
func newMapFlagViewForCommand(command string, input map[string]interface{}) mapFlagView {
|
||||
fv := mapFlagView{raw: input, defaults: map[string]interface{}{}, command: command}
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return fv
|
||||
}
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
return fv
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Kind == "system" || df.Default == "" {
|
||||
continue
|
||||
}
|
||||
fv.defaults[df.Name] = typedDefault(df)
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// typedDefault converts a flag's string default to the Go type matching its
|
||||
// declared kind, so Int()/Bool()/Float64() see the right type.
|
||||
func typedDefault(df flagDef) interface{} {
|
||||
switch df.Type {
|
||||
case "bool":
|
||||
return df.Default == "true"
|
||||
case "int":
|
||||
var n int
|
||||
fmt.Sscanf(df.Default, "%d", &n)
|
||||
return n
|
||||
case "float64":
|
||||
var f float64
|
||||
fmt.Sscanf(df.Default, "%g", &f)
|
||||
return f
|
||||
default:
|
||||
return df.Default
|
||||
}
|
||||
}
|
||||
|
||||
// lookup resolves a flag name for a VALUE read: user input first (hyphen↔
|
||||
// underscore tolerant), then the seeded default. Returns the value and whether
|
||||
// it was found in either source.
|
||||
func (m mapFlagView) lookup(name string) (interface{}, bool) {
|
||||
if v, ok := m.lookupRaw(name); ok {
|
||||
return v, true
|
||||
}
|
||||
if m.defaults != nil {
|
||||
if v, ok := m.defaults[name]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// lookupRaw resolves a flag name against the user-supplied input only, trying
|
||||
// the exact key then the hyphen↔underscore variants.
|
||||
func (m mapFlagView) lookupRaw(name string) (interface{}, bool) {
|
||||
if v, ok := m.raw[name]; ok {
|
||||
return v, true
|
||||
}
|
||||
if alt := strings.ReplaceAll(name, "-", "_"); alt != name {
|
||||
if v, ok := m.raw[alt]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
|
||||
if v, ok := m.raw[alt]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m mapFlagView) Str(name string) string {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case bool, float64, int, int64:
|
||||
b, _ := json.Marshal(t)
|
||||
return string(b)
|
||||
default:
|
||||
// Arrays / objects (cells, properties, sort-keys, options, ...) are
|
||||
// re-encoded so the translator's parseJSONFlag re-parses them.
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
func (m mapFlagView) Int(name string) int {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m mapFlagView) Float64(name string) float64 {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m mapFlagView) Bool(name string) bool {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func (m mapFlagView) StrArray(name string) []string {
|
||||
return m.strSliceLike(name)
|
||||
}
|
||||
|
||||
func (m mapFlagView) StrSlice(name string) []string {
|
||||
return m.strSliceLike(name)
|
||||
}
|
||||
|
||||
func (m mapFlagView) strSliceLike(name string) []string {
|
||||
v, ok := m.lookup(name)
|
||||
if !ok || v == nil {
|
||||
return nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
// CSV / comma-separated (matches cobra StringSlice behavior).
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(t, ",")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mapFlagView) Changed(name string) bool {
|
||||
_, ok := m.lookupRaw(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// validateRawTypes rejects sub-op input fields whose JSON type contradicts the
|
||||
// flag's declared type in flag-defs. +batch-update skips parse-time schema
|
||||
// validation for `operations`, and Int/Float64/Bool silently fall back to
|
||||
// the zero value on a type mismatch — so without this guard a wrong-typed scalar
|
||||
// (e.g. "index":"abc" or "multiple":"true") would land as 0 / false instead of
|
||||
// erroring, writing to the wrong place. Only numeric and boolean flags are
|
||||
// checked; string and composite (array/object) flags stay permissive because
|
||||
// Str() intentionally coerces them and the translator/schema validates shape.
|
||||
//
|
||||
// Returns a bare error; the +batch-update translator wraps it with the
|
||||
// operations[i] (<shortcut>) context.
|
||||
func (m mapFlagView) validateRawTypes() error {
|
||||
if len(m.raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
defs, err := loadFlagDefs()
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // fail-open: if flag-defs can't load, skip type validation rather than block the batch
|
||||
}
|
||||
spec, ok := defs[m.command]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
declaredType := make(map[string]string, len(spec.Flags))
|
||||
for _, df := range spec.Flags {
|
||||
declaredType[df.Name] = df.Type
|
||||
}
|
||||
for rawKey, val := range m.raw {
|
||||
name := rawKey
|
||||
typ, ok := declaredType[name]
|
||||
if !ok {
|
||||
// flag-defs use hyphen names; tolerate the underscore form users send.
|
||||
name = strings.ReplaceAll(rawKey, "_", "-")
|
||||
typ, ok = declaredType[name]
|
||||
}
|
||||
if !ok {
|
||||
continue // unknown key — leave it for the translator / schema layer
|
||||
}
|
||||
switch typ {
|
||||
case "int":
|
||||
// Int(): float64 → int(t) truncates, so a non-integer number would
|
||||
// be silently floored (1.9 → 1). Standalone cobra rejects it at
|
||||
// parse time; reject here too to keep batch/standalone parity.
|
||||
f, isNum := val.(float64)
|
||||
if !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
if math.Trunc(f) != f {
|
||||
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
|
||||
}
|
||||
case "float64":
|
||||
if _, isNum := val.(float64); !isNum {
|
||||
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
case "bool":
|
||||
if _, isBool := val.(bool); !isBool {
|
||||
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonTypeName names the JSON kind of a value decoded by encoding/json, for
|
||||
// type-mismatch error messages.
|
||||
func jsonTypeName(v interface{}) string {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
return "boolean"
|
||||
case float64:
|
||||
return "number"
|
||||
case string:
|
||||
return "string"
|
||||
case []interface{}:
|
||||
return "array"
|
||||
case map[string]interface{}:
|
||||
return "object"
|
||||
default:
|
||||
return fmt.Sprintf("%T", v)
|
||||
}
|
||||
}
|
||||
13
shortcuts/sheets/generate.go
Normal file
13
shortcuts/sheets/generate.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
// flag_defs_gen.go and flag_schemas_gen.go are generated from the canonical
|
||||
// data/*.json spec artifacts (synced from sheet-skill-spec). After the sync
|
||||
// script updates data/flag-defs.json or data/flag-schemas.json, regenerate
|
||||
// the compiled Go with:
|
||||
//
|
||||
// go generate ./shortcuts/sheets/...
|
||||
//
|
||||
//go:generate go run ./internal/gen
|
||||
@@ -1,51 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package sheets contains lark-sheets shortcuts aligned with the
|
||||
// sheet-skill-spec canonical layout. Each shortcut wraps a single
|
||||
// sheet-ai-skills tool behind the One-OpenAPI endpoint
|
||||
// (sheet_ai/v2/.../tools/invoke_{read,write}).
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var (
|
||||
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
|
||||
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
|
||||
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
|
||||
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
|
||||
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
|
||||
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
|
||||
)
|
||||
|
||||
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\!`, "!", `\!`, "!", "!", "!")
|
||||
|
||||
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
|
||||
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
|
||||
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
|
||||
if err != nil {
|
||||
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
|
||||
// pair shared by every sheets canonical shortcut and returns the resolved
|
||||
// token. Network-free, safe to call from Validate and DryRun.
|
||||
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
if err := common.ExactlyOne(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sheets, _ := data["sheets"].([]interface{})
|
||||
if len(sheets) > 0 {
|
||||
sheet, _ := sheets[0].(map[string]interface{})
|
||||
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
|
||||
return id, nil
|
||||
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
|
||||
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
|
||||
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", common.FlagErrorf("--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// extractSpreadsheetToken extracts spreadsheet token from URL.
|
||||
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
|
||||
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
|
||||
// prefix is present (callers must check token != originalInput).
|
||||
func extractSpreadsheetToken(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
prefixes := []string{"/sheets/", "/spreadsheets/"}
|
||||
for _, prefix := range prefixes {
|
||||
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
|
||||
if idx := strings.Index(input, prefix); idx >= 0 {
|
||||
token := input[idx+len(prefix):]
|
||||
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
|
||||
@@ -57,183 +58,254 @@ func extractSpreadsheetToken(input string) string {
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizeSheetRange(sheetID, input string) string {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID == "" {
|
||||
return input
|
||||
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
|
||||
// returns whichever was supplied. Network-free.
|
||||
//
|
||||
// Returned tuple: (sheetID, sheetName). Exactly one is non-empty — callers
|
||||
// pass both through to the tool input; the server picks whichever fits.
|
||||
func resolveSheetSelector(runtime *common.RuntimeContext) (sheetID, sheetName string, err error) {
|
||||
if err := common.ExactlyOne(runtime, "sheet-id", "sheet-name"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return sheetID + "!" + input
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func normalizePointRange(sheetID, input string) string {
|
||||
input = normalizeSheetRange(sheetID, input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
rangeSheetID, subRange, ok := splitSheetRange(input)
|
||||
if !ok || !singleCellRangePattern.MatchString(subRange) {
|
||||
return input
|
||||
}
|
||||
return rangeSheetID + "!" + subRange + ":" + subRange
|
||||
}
|
||||
|
||||
func normalizeWriteRange(sheetID, input string, values interface{}) string {
|
||||
rows, cols := matrixDimensions(values)
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return buildRectRange(sheetID, "A1", rows, cols)
|
||||
}
|
||||
|
||||
input = normalizeSheetRange(sheetID, input)
|
||||
rangeSheetID, subRange, ok := splitSheetRange(input)
|
||||
if !ok {
|
||||
return buildRectRange(input, "A1", rows, cols)
|
||||
}
|
||||
if singleCellRangePattern.MatchString(subRange) {
|
||||
return buildRectRange(rangeSheetID, subRange, rows, cols)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func validateSheetRangeInput(sheetID, input string) error {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" || strings.Contains(input, "!") || sheetID != "" {
|
||||
return nil
|
||||
}
|
||||
if looksLikeRelativeRange(input) {
|
||||
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
|
||||
// invalid for single-cell operations like write-image. Empty and single-cell
|
||||
// values pass through.
|
||||
func validateSingleCellRange(input string) error {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
// Extract the sub-range after the sheet ID prefix, if present.
|
||||
subRange := input
|
||||
if _, sr, ok := splitSheetRange(input); ok {
|
||||
subRange = sr
|
||||
}
|
||||
if cellSpanRangePattern.MatchString(subRange) {
|
||||
parts := strings.SplitN(subRange, ":", 2)
|
||||
if strings.EqualFold(parts[0], parts[1]) {
|
||||
return nil
|
||||
if id := strings.TrimSpace(runtime.Str("sheet-id")); id != "" {
|
||||
if err := validate.RejectControlChars(id, "sheet-id"); err != nil {
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return id, "", nil
|
||||
}
|
||||
name := strings.TrimSpace(runtime.Str("sheet-name"))
|
||||
if err := validate.RejectControlChars(name, "sheet-name"); err != nil {
|
||||
return "", "", common.FlagErrorf("%v", err)
|
||||
}
|
||||
return "", name, nil
|
||||
}
|
||||
|
||||
// validateViaInput shrinks a shortcut's Validate to the minimal
|
||||
// "token + ask the xxxInput builder if everything else is OK" pattern.
|
||||
// The builder owns the sheet selector and shortcut-specific checks
|
||||
// (--range required, --start >= 0, ...), so Validate no longer duplicates
|
||||
// them — the same error fires whether the shortcut runs standalone or as a
|
||||
// +batch-update sub-op. Use the inline form when the builder needs extra
|
||||
// arguments (operation enum, withMergeType bool, ...).
|
||||
func validateViaInput(
|
||||
build func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error),
|
||||
) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return 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 = build(runtime, token, sheetID, sheetName)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// requireSheetSelector is the flagView-agnostic counterpart of
|
||||
// resolveSheetSelector: given the already-extracted (sheetID, sheetName) pair,
|
||||
// it enforces the same XOR and control-char rules.
|
||||
//
|
||||
// Every batchable xxxInput builder calls this at the top so the same friendly
|
||||
// error fires whether the shortcut runs standalone (Validate sees the error
|
||||
// through the builder) or as a +batch-update sub-op (translator sees it
|
||||
// directly, prefixed by operations[i]). Without this, batch sub-ops
|
||||
// missing --sheet-id would slip through CLI validation and only fail on the
|
||||
// server with an opaque "sheet undefined not found".
|
||||
func requireSheetSelector(sheetID, sheetName string) error {
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID == "" && sheetName == "" {
|
||||
return common.FlagErrorf("specify at least one of --sheet-id or --sheet-name")
|
||||
}
|
||||
if sheetID != "" && sheetName != "" {
|
||||
return common.FlagErrorf("--sheet-id and --sheet-name are mutually exclusive")
|
||||
}
|
||||
if sheetID != "" {
|
||||
if err := validate.RejectControlChars(sheetID, "sheet-id"); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
} else {
|
||||
if err := validate.RejectControlChars(sheetName, "sheet-name"); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeRelativeRange(input string) bool {
|
||||
input = normalizeSheetRangeSeparators(input)
|
||||
if input == "" {
|
||||
return false
|
||||
// optionalSheetSelector is the "at most one" counterpart of
|
||||
// requireSheetSelector: both empty is acceptable (the backend tool then
|
||||
// decides what to do — e.g. manage_pivot_table_object auto-creates a new
|
||||
// sub-sheet to host the pivot), and both set is rejected. Control-char
|
||||
// validation still applies whenever a value is provided.
|
||||
//
|
||||
// Used by shortcuts whose backend tool treats sheet_id/sheet_name as the
|
||||
// placement target rather than the operation context (currently only
|
||||
// +pivot-create). Other shortcuts continue to use requireSheetSelector.
|
||||
//
|
||||
// idFlagName / nameFlagName parameterize the flag names quoted back in
|
||||
// the mutex / control-char errors — +pivot-create exposes the placement
|
||||
// selector as `--target-sheet-id` / `--target-sheet-name`, not the
|
||||
// generic `--sheet-id` / `--sheet-name`, and the error wording must
|
||||
// match what the user actually typed.
|
||||
func optionalSheetSelector(sheetID, sheetName, idFlagName, nameFlagName string) error {
|
||||
sheetID = strings.TrimSpace(sheetID)
|
||||
sheetName = strings.TrimSpace(sheetName)
|
||||
if sheetID != "" && sheetName != "" {
|
||||
return common.FlagErrorf("--%s and --%s are mutually exclusive", idFlagName, nameFlagName)
|
||||
}
|
||||
return singleCellRangePattern.MatchString(input) ||
|
||||
cellSpanRangePattern.MatchString(input) ||
|
||||
cellToColRangePattern.MatchString(input) ||
|
||||
colSpanRangePattern.MatchString(input) ||
|
||||
rowSpanRangePattern.MatchString(input)
|
||||
if sheetID != "" {
|
||||
if err := validate.RejectControlChars(sheetID, idFlagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
} else if sheetName != "" {
|
||||
if err := validate.RejectControlChars(sheetName, nameFlagName); err != nil {
|
||||
return common.FlagErrorf("%v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
|
||||
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
// sheetSelectorForToolInput packs --sheet-id / --sheet-name into the tool
|
||||
// input map, omitting empty fields. Use after resolveSheetSelector returns.
|
||||
func sheetSelectorForToolInput(input map[string]interface{}, sheetID, sheetName string) {
|
||||
if sheetID != "" {
|
||||
input["sheet_id"] = sheetID
|
||||
}
|
||||
if sheetName != "" {
|
||||
input["sheet_name"] = sheetName
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func normalizeSheetRangeSeparators(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return input
|
||||
}
|
||||
return sheetRangeSeparatorReplacer.Replace(input)
|
||||
}
|
||||
|
||||
func buildRectRange(sheetID, anchor string, rows, cols int) string {
|
||||
if sheetID == "" {
|
||||
return ""
|
||||
}
|
||||
if rows < 1 {
|
||||
rows = 1
|
||||
}
|
||||
if cols < 1 {
|
||||
cols = 1
|
||||
}
|
||||
endCell, err := offsetCell(anchor, rows-1, cols-1)
|
||||
if err != nil {
|
||||
// sheetSelectorPlaceholder returns a human-readable identifier for the
|
||||
// selected sheet, suitable for DryRun output. Avoids leaking that --sheet-name
|
||||
// would be resolved server-side at execute time.
|
||||
func sheetSelectorPlaceholder(sheetID, sheetName string) string {
|
||||
if sheetID != "" {
|
||||
return sheetID
|
||||
}
|
||||
return sheetID + "!" + anchor + ":" + endCell
|
||||
return "<resolve:" + sheetName + ">"
|
||||
}
|
||||
|
||||
func matrixDimensions(values interface{}) (rows, cols int) {
|
||||
rowList, ok := values.([]interface{})
|
||||
if !ok || len(rowList) == 0 {
|
||||
return 1, 1
|
||||
// parseJSONFlag parses a JSON string from a flag value. Returns nil when the
|
||||
// flag is empty (caller decides if that's acceptable). Used by --data /
|
||||
// --style / --options / --ranges / --colors and friends.
|
||||
func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
raw := strings.TrimSpace(runtime.Str(name))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rows = len(rowList)
|
||||
for _, row := range rowList {
|
||||
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
|
||||
cols = len(cells)
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil, common.FlagErrorf("--%s: invalid JSON: %v", name, err)
|
||||
}
|
||||
if cols == 0 {
|
||||
cols = 1
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
// --properties (validated at the input-builder tail after enhance
|
||||
// hooks fill in flat-flag-derived fields) and any flag without an
|
||||
// embedded schema entry.
|
||||
if err := validateParsedJSONFlag(runtime, name, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, cols
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
|
||||
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
|
||||
if len(matches) != 3 {
|
||||
return "", fmt.Errorf("invalid cell reference: %s", cell)
|
||||
}
|
||||
colIndex := columnNameToIndex(matches[1])
|
||||
if colIndex < 1 {
|
||||
return "", fmt.Errorf("invalid column: %s", matches[1])
|
||||
}
|
||||
rowIndex, err := strconv.Atoi(matches[2])
|
||||
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
|
||||
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON object", name)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func columnNameToIndex(name string) int {
|
||||
name = strings.ToUpper(strings.TrimSpace(name))
|
||||
if name == "" {
|
||||
return 0
|
||||
// requireJSONArray is parseJSONFlag + a type assertion to []interface{}.
|
||||
func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index := 0
|
||||
for _, r := range name {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return 0
|
||||
}
|
||||
index = index*26 + int(r-'A'+1)
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--%s is required", name)
|
||||
}
|
||||
return index
|
||||
a, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--%s must be a JSON array", name)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func columnIndexToName(index int) string {
|
||||
if index < 1 {
|
||||
return ""
|
||||
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
|
||||
|
||||
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
|
||||
// cell_styles map expected by set_cell_range. Skips any flag the user
|
||||
// didn't set so partial styles work.
|
||||
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
style := map[string]interface{}{}
|
||||
if v := runtime.Str("background-color"); v != "" {
|
||||
style["background_color"] = v
|
||||
}
|
||||
var out []byte
|
||||
for index > 0 {
|
||||
index--
|
||||
out = append([]byte{byte('A' + index%26)}, out...)
|
||||
index /= 26
|
||||
if v := runtime.Str("font-color"); v != "" {
|
||||
style["font_color"] = v
|
||||
}
|
||||
return string(out)
|
||||
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
|
||||
style["font_size"] = runtime.Float64("font-size")
|
||||
}
|
||||
if v := runtime.Str("font-style"); v != "" {
|
||||
style["font_style"] = v
|
||||
}
|
||||
if v := runtime.Str("font-weight"); v != "" {
|
||||
style["font_weight"] = v
|
||||
}
|
||||
if v := runtime.Str("font-line"); v != "" {
|
||||
style["font_line"] = v
|
||||
}
|
||||
if v := runtime.Str("horizontal-alignment"); v != "" {
|
||||
style["horizontal_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("vertical-alignment"); v != "" {
|
||||
style["vertical_alignment"] = v
|
||||
}
|
||||
if v := runtime.Str("word-wrap"); v != "" {
|
||||
style["word_wrap"] = v
|
||||
}
|
||||
if v := runtime.Str("number-format"); v != "" {
|
||||
style["number_format"] = v
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
if runtime.Str("border-styles") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := parseJSONFlag(runtime, "border-styles")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--border-styles must be a JSON object")
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// requireAnyStyleFlag ensures at least one style-defining flag (style or
|
||||
// border) is set — otherwise the request would do nothing.
|
||||
func requireAnyStyleFlag(runtime flagView) error {
|
||||
if len(buildCellStyleFromFlags(runtime)) > 0 {
|
||||
return nil
|
||||
}
|
||||
if runtime.Str("border-styles") != "" {
|
||||
return nil
|
||||
}
|
||||
return common.FlagErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)")
|
||||
}
|
||||
|
||||
203
shortcuts/sheets/helpers_test.go
Normal file
203
shortcuts/sheets/helpers_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testConfig returns a CliConfig wired with a stable user identity. Tests
|
||||
// keep the AppID test-prefixed so logs / metrics can spot them.
|
||||
func testConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-sheets-" + suffix,
|
||||
AppSecret: "secret-sheets-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_test_user",
|
||||
}
|
||||
}
|
||||
|
||||
// newTestRig spins up a Factory wired with httpmock + the given shortcut
|
||||
// mounted into a "sheets" parent command. Returns the cobra.Command ready
|
||||
// to SetArgs / Execute, plus the stdout / stderr buffers and the registry.
|
||||
func newTestRig(t *testing.T, sc common.Shortcut) (*cobra.Command, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, testConfig(t))
|
||||
parent := &cobra.Command{Use: "sheets"}
|
||||
sc.Mount(parent, f)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
return parent, stdout, stderr, reg
|
||||
}
|
||||
|
||||
// runShortcut executes the shortcut with the given args and returns the
|
||||
// captured stdout text. Mirrors the legacy package's parent.Execute()
|
||||
// flow so test cases stay close to real CLI behavior.
|
||||
func runShortcut(t *testing.T, sc common.Shortcut, args []string) (string, error) {
|
||||
t.Helper()
|
||||
parent, stdout, _, _ := newTestRig(t, sc)
|
||||
parent.SetArgs(append([]string{sc.Command}, args...))
|
||||
err := parent.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
// runShortcutCapturingErr is runShortcut but also returns the stderr text
|
||||
// so validation tests can inspect error envelopes.
|
||||
func runShortcutCapturingErr(t *testing.T, sc common.Shortcut, args []string) (stdoutStr, stderrStr string, err error) {
|
||||
t.Helper()
|
||||
parent, stdout, stderr, _ := newTestRig(t, sc)
|
||||
parent.SetArgs(append([]string{sc.Command}, args...))
|
||||
err = parent.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
// runShortcutWithStubs is runShortcut + a slice of httpmock stubs.
|
||||
// Stubs are registered before execute so the recorded API calls are
|
||||
// served from the registry instead of touching the network.
|
||||
func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs ...*httpmock.Stub) (string, error) {
|
||||
t.Helper()
|
||||
parent, stdout, _, reg := newTestRig(t, sc)
|
||||
for _, s := range stubs {
|
||||
reg.Register(s)
|
||||
}
|
||||
parent.SetArgs(append([]string{sc.Command}, args...))
|
||||
err := parent.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
// parseDryRunBody runs the shortcut in --dry-run and returns the first
|
||||
// api call's body. The dry-run output format is:
|
||||
//
|
||||
// === Dry Run ===
|
||||
// { "api": [{...}], ... }
|
||||
//
|
||||
// Tests use this to assert the One-OpenAPI wire body is constructed
|
||||
// correctly without exercising the real endpoint.
|
||||
func parseDryRunBody(t *testing.T, sc common.Shortcut, args []string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
||||
}
|
||||
return decodeDryRunFirstCall(t, out)
|
||||
}
|
||||
|
||||
// parseDryRunAPI returns the full list of `api` entries from a dry-run
|
||||
// output — used by shortcuts that emit multiple calls (e.g.
|
||||
// +workbook-export, +cells-set-image, +cells-batch-set-style).
|
||||
func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface{} {
|
||||
t.Helper()
|
||||
out, err := runShortcut(t, sc, append(args, "--dry-run"))
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
|
||||
}
|
||||
dryRun := decodeDryRunRaw(t, out)
|
||||
calls, _ := dryRun["api"].([]interface{})
|
||||
return calls
|
||||
}
|
||||
|
||||
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
|
||||
t.Helper()
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("dry-run output has no JSON body:\n%s", out)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &m); err != nil {
|
||||
t.Fatalf("failed to parse dry-run JSON: %v\nraw=%s", err, out)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func decodeDryRunFirstCall(t *testing.T, out string) map[string]interface{} {
|
||||
t.Helper()
|
||||
dryRun := decodeDryRunRaw(t, out)
|
||||
calls, ok := dryRun["api"].([]interface{})
|
||||
if !ok || len(calls) == 0 {
|
||||
t.Fatalf("dry-run api array empty or wrong shape: %#v", dryRun)
|
||||
}
|
||||
call, _ := calls[0].(map[string]interface{})
|
||||
body, _ := call["body"].(map[string]interface{})
|
||||
if body == nil {
|
||||
t.Fatalf("dry-run first call has no body: %#v", call)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// decodeToolInput parses the JSON-string `input` field embedded in a
|
||||
// dry-run body whose tool_name matches `expected`. Returns the decoded
|
||||
// tool input map so tests can assert on specific input fields.
|
||||
func decodeToolInput(t *testing.T, body map[string]interface{}, expectedToolName string) map[string]interface{} {
|
||||
t.Helper()
|
||||
if got, _ := body["tool_name"].(string); got != expectedToolName {
|
||||
t.Fatalf("tool_name = %q, want %q", got, expectedToolName)
|
||||
}
|
||||
rawInput, _ := body["input"].(string)
|
||||
if rawInput == "" {
|
||||
t.Fatalf("body.input is empty: %#v", body)
|
||||
}
|
||||
var input map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(rawInput), &input); err != nil {
|
||||
t.Fatalf("failed to parse tool input JSON: %v\nraw=%s", err, rawInput)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// decodeEnvelopeData parses a successful envelope's data field — used by
|
||||
// execute-path tests that go through the full callTool stack with stubs.
|
||||
func decodeEnvelopeData(t *testing.T, out string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if ok, _ := envelope["ok"].(bool); !ok {
|
||||
t.Fatalf("envelope.ok=false: %#v", envelope)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
return data
|
||||
}
|
||||
|
||||
// toolOutputStub builds an httpmock stub for the One-OpenAPI invoke_read
|
||||
// or invoke_write endpoint. `outputJSON` is the JSON string the tool
|
||||
// returns in data.output.
|
||||
func toolOutputStub(token, kind string, outputJSON string) *httpmock.Stub {
|
||||
suffix := "invoke_read"
|
||||
if kind == "write" {
|
||||
suffix = "invoke_write"
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + token + "/tools/" + suffix,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"output": outputJSON,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// commonArgsURL is the typical --url and --sheet-id pair used by sheet-
|
||||
// level tests.
|
||||
const (
|
||||
testToken = "shtcnTestTOK"
|
||||
testURL = "https://example.feishu.cn/sheets/shtcnTestTOK"
|
||||
testSheetID = "shtSubA"
|
||||
testSheetID2 = "shtSubB"
|
||||
)
|
||||
208
shortcuts/sheets/internal/gen/main.go
Normal file
208
shortcuts/sheets/internal/gen/main.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command gen regenerates flag_defs_gen.go and flag_schemas_gen.go from the
|
||||
// data/*.json spec artifacts, so command startup pays no JSON unmarshal.
|
||||
//
|
||||
// Invoked via `go generate ./shortcuts/sheets/...` (see ../../generate.go).
|
||||
// data/*.json stays the canonical source (synced from sheet-skill-spec); the
|
||||
// *_gen.go files are committed, derived artifacts. CI should run go generate
|
||||
// and fail on a dirty tree to keep them in lockstep.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type flagDef struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
Required string `json:"required"`
|
||||
Desc string `json:"desc"`
|
||||
Default string `json:"default"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Enum []string `json:"enum"`
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
|
||||
type commandDef struct {
|
||||
Risk string `json:"risk"`
|
||||
Flags []flagDef `json:"flags"`
|
||||
}
|
||||
|
||||
// sheetsDir resolves shortcuts/sheets from this generator's own location, so
|
||||
// the tool works regardless of the caller's working directory.
|
||||
func sheetsDir() string {
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
log.Fatal("gen: cannot resolve caller path")
|
||||
}
|
||||
// thisFile = <repo>/shortcuts/sheets/internal/gen/main.go
|
||||
return filepath.Join(filepath.Dir(thisFile), "..", "..")
|
||||
}
|
||||
|
||||
func writeFormatted(path string, b *bytes.Buffer) {
|
||||
out, err := format.Source(b.Bytes())
|
||||
if err != nil {
|
||||
log.Fatalf("gen: format %s: %v", filepath.Base(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, out, 0o644); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", filepath.Base(path), len(out))
|
||||
}
|
||||
|
||||
func main() {
|
||||
dir := sheetsDir()
|
||||
genFlagDefs(dir)
|
||||
genFlagSchemas(dir)
|
||||
}
|
||||
|
||||
const flagDefsHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-defs.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
|
||||
// metadata for every shortcut, emitted as a Go literal so command startup
|
||||
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
|
||||
// with ` + "`go generate ./shortcuts/sheets/...`" + ` after data/flag-defs.json
|
||||
// changes.
|
||||
var flagDefs = map[string]commandDef{
|
||||
`
|
||||
|
||||
func sliceLit(s []string) string {
|
||||
parts := make([]string, len(s))
|
||||
for i, v := range s {
|
||||
parts[i] = fmt.Sprintf("%q", v)
|
||||
}
|
||||
return "[]string{" + strings.Join(parts, ", ") + "}"
|
||||
}
|
||||
|
||||
func flagLit(f flagDef) string {
|
||||
var p []string
|
||||
if f.Name != "" {
|
||||
p = append(p, fmt.Sprintf("Name: %q", f.Name))
|
||||
}
|
||||
if f.Kind != "" {
|
||||
p = append(p, fmt.Sprintf("Kind: %q", f.Kind))
|
||||
}
|
||||
if f.Type != "" {
|
||||
p = append(p, fmt.Sprintf("Type: %q", f.Type))
|
||||
}
|
||||
if f.Required != "" {
|
||||
p = append(p, fmt.Sprintf("Required: %q", f.Required))
|
||||
}
|
||||
if f.Desc != "" {
|
||||
p = append(p, fmt.Sprintf("Desc: %q", f.Desc))
|
||||
}
|
||||
if f.Default != "" {
|
||||
p = append(p, fmt.Sprintf("Default: %q", f.Default))
|
||||
}
|
||||
if f.Hidden {
|
||||
p = append(p, "Hidden: true")
|
||||
}
|
||||
if f.Enum != nil {
|
||||
p = append(p, "Enum: "+sliceLit(f.Enum))
|
||||
}
|
||||
if f.Input != nil {
|
||||
p = append(p, "Input: "+sliceLit(f.Input))
|
||||
}
|
||||
return "{" + strings.Join(p, ", ") + "}"
|
||||
}
|
||||
|
||||
func genFlagDefs(dir string) {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-defs.json"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var defs map[string]commandDef
|
||||
if err := json.Unmarshal(raw, &defs); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(defs))
|
||||
for k := range defs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b bytes.Buffer
|
||||
b.WriteString(flagDefsHeader)
|
||||
for _, k := range keys {
|
||||
cd := defs[k]
|
||||
fmt.Fprintf(&b, "%q: {\n", k)
|
||||
if cd.Risk != "" {
|
||||
fmt.Fprintf(&b, "Risk: %q,\n", cd.Risk)
|
||||
}
|
||||
if cd.Flags != nil {
|
||||
b.WriteString("Flags: []flagDef{\n")
|
||||
for _, f := range cd.Flags {
|
||||
b.WriteString(flagLit(f))
|
||||
b.WriteString(",\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
|
||||
writeFormatted(filepath.Join(dir, "flag_defs_gen.go"), &b)
|
||||
}
|
||||
|
||||
const flagSchemasHeader = `// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Code generated from data/flag-schemas.json; DO NOT EDIT.
|
||||
|
||||
package sheets
|
||||
|
||||
// commandsWithSchema is the set of shortcut commands that have at least one
|
||||
// introspectable composite flag in data/flag-schemas.json. Codegen'd so the
|
||||
// registration loop (shortcuts.go) and the validate fast-path can gate on it
|
||||
// without parsing the 256KB schema blob at startup (that parse used to run on
|
||||
// every CLI invocation, sheets or not). The 256KB is now only unmarshaled
|
||||
// on --print-schema or when validating a command that is in this set. Do not
|
||||
// hand-edit; regenerate with ` + "`go generate ./shortcuts/sheets/...`" + `.
|
||||
var commandsWithSchema = map[string]struct{}{
|
||||
`
|
||||
|
||||
func genFlagSchemas(dir string) {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "data", "flag-schemas.json"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
var doc struct {
|
||||
Flags map[string]json.RawMessage `json:"flags"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(doc.Flags))
|
||||
for k := range doc.Flags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b bytes.Buffer
|
||||
b.WriteString(flagSchemasHeader)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, "%q: {},\n", k)
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
|
||||
writeFormatted(filepath.Join(dir, "flag_schemas_gen.go"), &b)
|
||||
}
|
||||
502
shortcuts/sheets/lark_sheet_batch_update.go
Normal file
502
shortcuts/sheets/lark_sheet_batch_update.go
Normal file
@@ -0,0 +1,502 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_batch_update ──────────────────────────────────────────
|
||||
//
|
||||
// One tool (batch_update), four shortcuts:
|
||||
//
|
||||
// - +batch-update user supplies a CLI-shape operations array
|
||||
// [{shortcut, input}, ...]; CLI translates to
|
||||
// MCP shape {tool_name, input(+operation)} via
|
||||
// batchOpDispatch before invoking the tool
|
||||
// (high-risk-write — anything in batchOpDispatch
|
||||
// can be inside)
|
||||
// - +cells-batch-set-style fan a single style across many ranges
|
||||
// - +dropdown-update install/replace the same dropdown across
|
||||
// many ranges in one atomic batch
|
||||
// - +dropdown-delete clear data_validation across many ranges
|
||||
// (high-risk-write)
|
||||
//
|
||||
// The tool's contract (post-translation):
|
||||
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
|
||||
//
|
||||
// continue_on_error defaults to false (strict transaction): any failure
|
||||
// rolls back the whole batch. CLI leaves the default in place for the
|
||||
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
|
||||
// only +batch-update lets callers flip it via --continue-on-error.
|
||||
|
||||
// BatchUpdate accepts a CLI-shape operations array (each item
|
||||
// {shortcut, input}); on Validate / DryRun / Execute we translate each
|
||||
// sub-op via batchOpDispatch (see batch_op_dispatch.go) into the MCP
|
||||
// {tool_name, input(+operation)} form before calling the underlying
|
||||
// batch_update tool.
|
||||
var BatchUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+batch-update",
|
||||
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+batch-update"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Run the full translation in Validate so shape errors surface before
|
||||
// DryRun / Execute. Translator is pure (no network), so re-running it
|
||||
// in DryRun / Execute below is fine.
|
||||
if _, err := batchUpdateInput(runtime, token); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := batchUpdateInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := batchUpdateInput(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
|
||||
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
|
||||
},
|
||||
}
|
||||
|
||||
// batchUpdateInput translates the user-supplied CLI-shape operations array
|
||||
// into the MCP batch_update payload. Returns FlagErrorf-typed errors on
|
||||
// any per-op shape problem (translator validates each entry).
|
||||
func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translated, err := translateBatchOperations(rawOps, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": translated,
|
||||
}
|
||||
if runtime.Changed("continue-on-error") {
|
||||
// An explicit --continue-on-error always wins over the envelope, so
|
||||
// --continue-on-error=false keeps the strict-transaction default even
|
||||
// when the --operations envelope carries continue_on_error:true.
|
||||
if runtime.Bool("continue-on-error") {
|
||||
input["continue_on_error"] = true
|
||||
}
|
||||
} else if envelope, _ := parseJSONFlag(runtime, "operations"); envelope != nil {
|
||||
// No explicit flag: honor an inline override when --operations is an
|
||||
// envelope object rather than a bare operations array.
|
||||
if m, ok := envelope.(map[string]interface{}); ok {
|
||||
if v, ok := m["continue_on_error"].(bool); ok && v {
|
||||
input["continue_on_error"] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
|
||||
// operations list directly) or an envelope object { operations, continue_on_error }
|
||||
// for back-compat with the legacy --data shape. Returns the operations array.
|
||||
func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
|
||||
v, err := parseJSONFlag(runtime, "operations")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v == nil {
|
||||
return nil, common.FlagErrorf("--operations is required")
|
||||
}
|
||||
if arr, ok := v.([]interface{}); ok {
|
||||
return arr, nil
|
||||
}
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
if ops, ok := m["operations"].([]interface{}); ok {
|
||||
return ops, nil
|
||||
}
|
||||
}
|
||||
return nil, common.FlagErrorf("--operations must be a JSON array (or { operations: [...] } envelope)")
|
||||
}
|
||||
|
||||
// CellsBatchSetStyle stamps one style block across many sheet-prefixed
|
||||
// ranges atomically. --ranges is a JSON array of sheet-prefixed A1
|
||||
// strings; the style is composed from the same flat flags as
|
||||
// +cells-set-style. CLI fans each range into a separate set_cell_range
|
||||
// op inside one batch_update.
|
||||
var CellsBatchSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-batch-set-style",
|
||||
Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-set-style"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateDropdownRanges(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := borderStylesFromFlag(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := cellsBatchSetStyleInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := cellsBatchSetStyleInput(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cellStyle := buildCellStyleFromFlags(runtime)
|
||||
borderStyles, err := borderStylesFromFlag(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prototype := map[string]interface{}{}
|
||||
if len(cellStyle) > 0 {
|
||||
prototype["cell_styles"] = cellStyle
|
||||
}
|
||||
if borderStyles != nil {
|
||||
prototype["border_styles"] = borderStyles
|
||||
}
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, cols, err := rangeDimensions(sub)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("range %q: %v", rng, err)
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": sheet,
|
||||
"range": sub,
|
||||
"cells": cells,
|
||||
},
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CellsBatchClear clears content / formats / both across many sheet-prefixed
|
||||
// ranges in one atomic batch. --ranges is a JSON array of sheet-prefixed A1
|
||||
// strings; --scope reuses the +cells-clear vocabulary (content / formats /
|
||||
// all). CLI fans each range into a separate clear_cell_range op inside one
|
||||
// batch_update. high-risk-write because clear is irreversible.
|
||||
var CellsBatchClear = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-batch-clear",
|
||||
Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-batch-clear"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateDropdownRanges(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := cellsBatchClearInput(runtime, token)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := cellsBatchClearInput(runtime, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return annotateEmbeddedBlockClearErr(err)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"Every --ranges item must carry a sheet prefix (e.g. \"Sheet1!A1:A10\"); all ranges are cleared with the same --scope.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
|
||||
func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clearType := normalizeClearType(runtime.Str("scope"))
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "clear_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": sheet,
|
||||
"range": sub,
|
||||
"clear_type": clearType,
|
||||
},
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DropdownUpdate installs/replaces a single dropdown on many ranges in one
|
||||
// atomic batch. Sheet ids come from the per-range sheet prefix.
|
||||
var DropdownUpdate = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-update",
|
||||
Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dropdown-update"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateDropdownRanges(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := validateDropdownSourceOrOptions(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
warnDropdownSourceRangeHighlight(runtime)
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := dropdownBatchInput(runtime, token, false)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dropdownBatchInput(runtime, token, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// DropdownDelete clears data_validation across many ranges atomically.
|
||||
var DropdownDelete = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-delete",
|
||||
Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dropdown-delete"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ranges) > 100 {
|
||||
return common.FlagErrorf("--ranges accepts at most 100 entries; got %d", len(ranges))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
input, _ := dropdownBatchInput(runtime, token, true)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dropdownBatchInput(runtime, token, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// dropdownBatchInput builds the batch_update payload for both
|
||||
// +dropdown-update (clear=false, data_validation populated) and
|
||||
// +dropdown-delete (clear=true, data_validation: null).
|
||||
func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool) (map[string]interface{}, error) {
|
||||
ranges, err := validateDropdownRanges(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prototype map[string]interface{}
|
||||
if clear {
|
||||
prototype = map[string]interface{}{"data_validation": nil}
|
||||
} else {
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prototype = map[string]interface{}{"data_validation": validation}
|
||||
}
|
||||
var ops []interface{}
|
||||
for _, rng := range ranges {
|
||||
sheet, sub, err := splitSheetPrefixedRange(rng)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, cols, err := rangeDimensions(sub)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("range %q: %v", rng, err)
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, prototype)
|
||||
ops = append(ops, map[string]interface{}{
|
||||
"tool_name": "set_cell_range",
|
||||
"input": map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_name": sheet,
|
||||
"range": sub,
|
||||
"cells": cells,
|
||||
},
|
||||
})
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operations": ops,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ─── helpers resurrected from B3 (used here + future skills) ──────────
|
||||
|
||||
// validateDropdownRanges parses --ranges, requires every entry to carry a
|
||||
// sheet prefix, and returns the parsed list.
|
||||
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
|
||||
raw, err := requireJSONArray(runtime, "ranges")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for i, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, common.FlagErrorf("--ranges[%d] must be a string", i)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.Contains(s, "!") {
|
||||
return nil, common.FlagErrorf("--ranges[%d] (%q) must include a sheet prefix", i, s)
|
||||
}
|
||||
// Validate the sheet!range shape up front so malformed entries like
|
||||
// "!A1" (no sheet), "Sheet1!" (no range) or "Sheet1!bad" (bad ref) fail
|
||||
// here at Validate instead of slipping through to DryRun/Execute.
|
||||
_, sub, err := splitSheetPrefixedRange(s)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--ranges[%d]: %v", i, err)
|
||||
}
|
||||
if _, _, err := rangeDimensions(sub); err != nil {
|
||||
return nil, common.FlagErrorf("--ranges[%d] (%q): %v", i, s, err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitSheetPrefixedRange splits "sheet1!A2:A100" into ("sheet1", "A2:A100").
|
||||
func splitSheetPrefixedRange(rng string) (sheet, sub string, err error) {
|
||||
idx := strings.Index(rng, "!")
|
||||
if idx <= 0 || idx == len(rng)-1 {
|
||||
return "", "", common.FlagErrorf("range %q must use sheet!range form", rng)
|
||||
}
|
||||
return strings.TrimSpace(rng[:idx]), strings.TrimSpace(rng[idx+1:]), nil
|
||||
}
|
||||
495
shortcuts/sheets/lark_sheet_batch_update_test.go
Normal file
495
shortcuts/sheets/lark_sheet_batch_update_test.go
Normal file
@@ -0,0 +1,495 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBatchUpdate_TranslatesShortcutToToolName verifies +batch-update
|
||||
// translates each CLI-shape sub-op ({shortcut, input}) to the MCP-shape
|
||||
// ({tool_name, input(+operation, +excel_id)}) before threading into
|
||||
// the underlying batch_update tool. Covers continue_on_error too.
|
||||
func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[
|
||||
{"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}},
|
||||
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"1","count":3}}
|
||||
]`,
|
||||
"--continue-on-error",
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations length = %d, want 2", len(ops))
|
||||
}
|
||||
if input["continue_on_error"] != true {
|
||||
t.Errorf("continue_on_error = %v, want true", input["continue_on_error"])
|
||||
}
|
||||
|
||||
// op[0]: +cells-set → set_cell_range, no operation field
|
||||
op0 := ops[0].(map[string]interface{})
|
||||
if op0["tool_name"] != "set_cell_range" {
|
||||
t.Errorf("op[0].tool_name = %v, want set_cell_range", op0["tool_name"])
|
||||
}
|
||||
in0, _ := op0["input"].(map[string]interface{})
|
||||
if in0["excel_id"] == nil {
|
||||
t.Errorf("op[0].input.excel_id missing (translator should inject)")
|
||||
}
|
||||
if _, has := in0["operation"]; has {
|
||||
t.Errorf("op[0].input.operation present, +cells-set should not inject one: %#v", in0)
|
||||
}
|
||||
|
||||
// op[1]: +dim-insert → modify_sheet_structure + operation:"insert"
|
||||
op1 := ops[1].(map[string]interface{})
|
||||
if op1["tool_name"] != "modify_sheet_structure" {
|
||||
t.Errorf("op[1].tool_name = %v, want modify_sheet_structure", op1["tool_name"])
|
||||
}
|
||||
in1, _ := op1["input"].(map[string]interface{})
|
||||
if in1["operation"] != "insert" {
|
||||
t.Errorf("op[1].input.operation = %v, want \"insert\"", in1["operation"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+cells-set","input":{}}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsBatchSetStyle_FansOutOps verifies multiple ranges produce one
|
||||
// set_cell_range op each, sharing the same style flags.
|
||||
func TestCellsBatchSetStyle_FansOutOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, CellsBatchSetStyle, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:B2","sheet1!D1:E2","sheet1!A5:A6"]`,
|
||||
"--font-weight", "bold",
|
||||
"--background-color", "#ffff00",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 3 {
|
||||
t.Fatalf("operations length = %d, want 3 (one per range)", len(ops))
|
||||
}
|
||||
for i, raw := range ops {
|
||||
op, _ := raw.(map[string]interface{})
|
||||
if op["tool_name"] != "set_cell_range" {
|
||||
t.Errorf("op[%d].tool_name = %v, want set_cell_range", i, op["tool_name"])
|
||||
}
|
||||
params, _ := op["input"].(map[string]interface{})
|
||||
if params["sheet_name"] != "sheet1" {
|
||||
t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"])
|
||||
}
|
||||
cells, _ := params["cells"].([]interface{})
|
||||
row, _ := cells[0].([]interface{})
|
||||
cell, _ := row[0].(map[string]interface{})
|
||||
style, _ := cell["cell_styles"].(map[string]interface{})
|
||||
if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" {
|
||||
t.Errorf("op[%d] cell_styles wrong: %#v", i, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsBatchClear_FansOutOps verifies multiple ranges produce one
|
||||
// clear_cell_range op each, all sharing the same --scope-derived clear_type,
|
||||
// with the sheet prefix split into sheet_name + bare range.
|
||||
func TestCellsBatchClear_FansOutOps(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A10","sheet2!C1:D5","sheet1!F3"]`,
|
||||
"--scope", "all",
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 3 {
|
||||
t.Fatalf("operations length = %d, want 3 (one per range)", len(ops))
|
||||
}
|
||||
wantSheet := []string{"sheet1", "sheet2", "sheet1"}
|
||||
wantRange := []string{"A1:A10", "C1:D5", "F3"}
|
||||
for i, raw := range ops {
|
||||
op, _ := raw.(map[string]interface{})
|
||||
if op["tool_name"] != "clear_cell_range" {
|
||||
t.Errorf("op[%d].tool_name = %v, want clear_cell_range", i, op["tool_name"])
|
||||
}
|
||||
params, _ := op["input"].(map[string]interface{})
|
||||
if params["sheet_name"] != wantSheet[i] {
|
||||
t.Errorf("op[%d].sheet_name = %v, want %s", i, params["sheet_name"], wantSheet[i])
|
||||
}
|
||||
if params["range"] != wantRange[i] {
|
||||
t.Errorf("op[%d].range = %v, want %s", i, params["range"], wantRange[i])
|
||||
}
|
||||
if params["clear_type"] != "all" {
|
||||
t.Errorf("op[%d].clear_type = %v, want all", i, params["clear_type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsBatchClear_ScopeDefaultsToContents verifies the default --scope
|
||||
// (content) maps to the tool's clear_type "contents" — identical to the
|
||||
// standalone +cells-clear normalization.
|
||||
func TestCellsBatchClear_ScopeDefaultsToContents(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:B2"]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
params, _ := ops[0].(map[string]interface{})["input"].(map[string]interface{})
|
||||
if params["clear_type"] != "contents" {
|
||||
t.Errorf("clear_type = %v, want contents (default scope)", params["clear_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsBatchClear_Guards covers the sheet-prefix requirement and the
|
||||
// high-risk-write confirmation gate.
|
||||
func TestCellsBatchClear_Guards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// sheetless range → prefix guard (shared with the dropdown fan-outs).
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["A1:A10"]`,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
|
||||
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// missing --yes → confirmation_required (high-risk-write).
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A10"]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected confirmation_required without --yes; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownUpdate_BatchPayload verifies the multi-range dropdown
|
||||
// update fans out into a single batch_update with one set_cell_range
|
||||
// op per range. Also covers --colors / --highlight -> highlight_colors
|
||||
// / enable_highlight propagation through dropdownBatchInput.
|
||||
func TestDropdownUpdate_BatchPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
|
||||
"--options", `["a","b","c"]`,
|
||||
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
|
||||
"--multiple", "--highlight",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations length = %d, want 2", len(ops))
|
||||
}
|
||||
for i, raw := range ops {
|
||||
op, _ := raw.(map[string]interface{})
|
||||
params, _ := op["input"].(map[string]interface{})
|
||||
cells, _ := params["cells"].([]interface{})
|
||||
if len(cells) != 4 {
|
||||
t.Errorf("op[%d] cells rows = %d, want 4 (A2:A5 / C2:C5)", i, len(cells))
|
||||
}
|
||||
row0, _ := cells[0].([]interface{})
|
||||
cell, _ := row0[0].(map[string]interface{})
|
||||
dv, _ := cell["data_validation"].(map[string]interface{})
|
||||
if dv == nil || dv["type"] != "list" {
|
||||
t.Errorf("op[%d] missing data_validation list: %#v", i, cell)
|
||||
}
|
||||
items, _ := dv["items"].([]interface{})
|
||||
if len(items) != 3 {
|
||||
t.Errorf("op[%d] data_validation.items length = %d, want 3", i, len(items))
|
||||
}
|
||||
if dv["support_multiple_values"] != true {
|
||||
t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"])
|
||||
}
|
||||
colors, _ := dv["highlight_colors"].([]interface{})
|
||||
if len(colors) != 3 {
|
||||
t.Errorf("op[%d] highlight_colors length = %d, want 3", i, len(colors))
|
||||
}
|
||||
if dv["enable_highlight"] != true {
|
||||
t.Errorf("op[%d] enable_highlight = %v, want true", i, dv["enable_highlight"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownDelete_BatchClearsValidation verifies delete sets
|
||||
// data_validation: null on every cell.
|
||||
func TestDropdownDelete_BatchClearsValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, DropdownDelete, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A2:A4"]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations length = %d, want 1", len(ops))
|
||||
}
|
||||
op := ops[0].(map[string]interface{})
|
||||
params, _ := op["input"].(map[string]interface{})
|
||||
cells, _ := params["cells"].([]interface{})
|
||||
for i, raw := range cells {
|
||||
row, _ := raw.([]interface{})
|
||||
cell, _ := row[0].(map[string]interface{})
|
||||
if _, present := cell["data_validation"]; !present {
|
||||
t.Errorf("row %d: data_validation key missing", i)
|
||||
continue
|
||||
}
|
||||
if cell["data_validation"] != nil {
|
||||
t.Errorf("row %d: data_validation = %v, want null", i, cell["data_validation"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdate_ValidationGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// dropdown-update with sheetless range
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["A2:A5"]`,
|
||||
"--options", `["a"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
|
||||
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// batch-update with empty operations
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[]`,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
|
||||
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// dropdown-update with non-array --options (object instead) → array guard
|
||||
// (now via schema validator at parseJSONFlag time)
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A2"]`,
|
||||
"--options", `{"not":"array"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
|
||||
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
|
||||
// validation: entries that merely contain "!" but are otherwise malformed (empty
|
||||
// sheet, empty range, or an unparseable A1 ref) must fail at Validate rather than
|
||||
// slip through to DryRun/Execute. Covers +dropdown-update / +dropdown-delete,
|
||||
// which fan out over --ranges.
|
||||
func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
ranges string
|
||||
want string
|
||||
}{
|
||||
{"no sheet prefix at all", `["A1:A5"]`, "must include a sheet prefix"},
|
||||
{"empty sheet name", `["!A1:A5"]`, "must use sheet!range form"},
|
||||
{"empty range after prefix", `["Sheet1!"]`, "must use sheet!range form"},
|
||||
{"unparseable ref", `["Sheet1!bad"]`, "invalid cell ref"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", tc.ranges,
|
||||
"--options", `["a"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
|
||||
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by
|
||||
// translateBatchOp: unknown shortcut, missing shortcut, banned (read /
|
||||
// fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc.
|
||||
func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
opsJSON string
|
||||
wantMatch string
|
||||
}{
|
||||
{
|
||||
name: "missing shortcut field",
|
||||
opsJSON: `[{"input":{"range":"A1"}}]`,
|
||||
wantMatch: "'shortcut' field is required",
|
||||
},
|
||||
{
|
||||
name: "empty shortcut string",
|
||||
opsJSON: `[{"shortcut":"","input":{}}]`,
|
||||
wantMatch: "'shortcut' must be a non-empty string",
|
||||
},
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
opsJSON: `[{"shortcut":"+cells-set-magic","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
},
|
||||
{
|
||||
name: "read op rejected",
|
||||
opsJSON: `[{"shortcut":"+cells-get","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
},
|
||||
{
|
||||
name: "nested batch-update rejected",
|
||||
opsJSON: `[{"shortcut":"+batch-update","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
},
|
||||
{
|
||||
name: "fan-out wrapper rejected",
|
||||
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
},
|
||||
{
|
||||
name: "fan-out wrapper +cells-batch-clear rejected",
|
||||
opsJSON: `[{"shortcut":"+cells-batch-clear","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
},
|
||||
{
|
||||
name: "legacy v2 +dim-move rejected",
|
||||
opsJSON: `[{"shortcut":"+dim-move","input":{}}]`,
|
||||
wantMatch: "not allowed in +batch-update",
|
||||
},
|
||||
{
|
||||
name: "user filled operation manually",
|
||||
opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","position":"1","count":1}}]`,
|
||||
wantMatch: "do not pass input.operation",
|
||||
},
|
||||
{
|
||||
name: "user filled excel_id",
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":{"excel_id":"shtcnX","range":"A1"}}]`,
|
||||
wantMatch: "do not pass input.excel_id",
|
||||
},
|
||||
{
|
||||
name: "user filled url",
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":{"url":"https://x.feishu.cn/sheets/sh","range":"A1"}}]`,
|
||||
wantMatch: "do not pass input.url",
|
||||
},
|
||||
{
|
||||
name: "extra top-level key",
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":{"range":"A1"},"tool_name":"oops"}]`,
|
||||
wantMatch: "unknown top-level key",
|
||||
},
|
||||
{
|
||||
name: "sub-op not an object",
|
||||
opsJSON: `["not-an-object"]`,
|
||||
wantMatch: "must be a JSON object",
|
||||
},
|
||||
{
|
||||
name: "input not an object",
|
||||
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
|
||||
wantMatch: "'input' must be a JSON object",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", tc.opsJSON,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
|
||||
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only
|
||||
// path: +dim-freeze always injects operation=freeze (count==0 unfreeze
|
||||
// path of the single shortcut is intentionally not supported in batch).
|
||||
func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","dimension":"row","count":2}}]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
ops, _ := input["operations"].([]interface{})
|
||||
op := ops[0].(map[string]interface{})
|
||||
if op["tool_name"] != "modify_sheet_structure" {
|
||||
t.Errorf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
|
||||
}
|
||||
in, _ := op["input"].(map[string]interface{})
|
||||
if in["operation"] != "freeze" {
|
||||
t.Errorf("operation = %v, want \"freeze\"", in["operation"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchUpdate_ResizeNoOperationField covers the resize_range dispatch:
|
||||
// mapping has no operationField, so input.operation must NOT be injected.
|
||||
func TestBatchUpdate_ResizeNoOperationField(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`,
|
||||
"--yes",
|
||||
})
|
||||
input := decodeToolInput(t, body, "batch_update")
|
||||
op := input["operations"].([]interface{})[0].(map[string]interface{})
|
||||
if op["tool_name"] != "resize_range" {
|
||||
t.Errorf("tool_name = %v, want resize_range", op["tool_name"])
|
||||
}
|
||||
in, _ := op["input"].(map[string]interface{})
|
||||
if _, has := in["operation"]; has {
|
||||
t.Errorf("operation should NOT be injected for resize_range; got %#v", in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitSheetPrefixedRange exercises the helper directly.
|
||||
func TestSplitSheetPrefixedRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
sheet, sub, err := splitSheetPrefixedRange("sheet1!A2:A100")
|
||||
if err != nil || sheet != "sheet1" || sub != "A2:A100" {
|
||||
t.Errorf("split = (%q,%q,%v), want (sheet1, A2:A100, nil)", sheet, sub, err)
|
||||
}
|
||||
if _, _, err := splitSheetPrefixedRange("A2:A100"); err == nil {
|
||||
t.Error("expected error on missing prefix")
|
||||
}
|
||||
if _, _, err := splitSheetPrefixedRange("!A2"); err == nil {
|
||||
t.Error("expected error on empty sheet name")
|
||||
}
|
||||
// Compile-time use of json import
|
||||
_ = json.Marshal
|
||||
}
|
||||
1043
shortcuts/sheets/lark_sheet_object_crud.go
Normal file
1043
shortcuts/sheets/lark_sheet_object_crud.go
Normal file
File diff suppressed because it is too large
Load Diff
672
shortcuts/sheets/lark_sheet_object_crud_test.go
Normal file
672
shortcuts/sheets/lark_sheet_object_crud_test.go
Normal file
@@ -0,0 +1,672 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestPivotPlacementWarn pins the advisory that fires only on the risky
|
||||
// +pivot-create combination — an explicit placement sheet with no offset —
|
||||
// and stays silent (or only conditionally reminds) everywhere else.
|
||||
func TestPivotPlacementWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
raw map[string]interface{}
|
||||
want string // "" none | "definite" names the sheet | "conditional" generic reminder
|
||||
}{
|
||||
{"no placement target → silent (default sub-sheet)",
|
||||
map[string]interface{}{"source": "'Sheet1'!A1:D100"}, ""},
|
||||
{"target-position offset → silent",
|
||||
map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100", "target-position": "H1"}, ""},
|
||||
{"range offset → silent",
|
||||
map[string]interface{}{"target-sheet-id": "sht_x", "range": "H1"}, ""},
|
||||
{"target name == source sheet, no offset → definite",
|
||||
map[string]interface{}{"target-sheet-name": "Sheet1", "source": "'Sheet1'!A1:D100"}, "definite"},
|
||||
{"case-insensitive name match → definite",
|
||||
map[string]interface{}{"target-sheet-name": "sheet1", "source": "'Sheet1'!A1:D100"}, "definite"},
|
||||
{"target name != source sheet → silent (distinct sheet is safe)",
|
||||
map[string]interface{}{"target-sheet-name": "PivotOut", "source": "'Sheet1'!A1:D100"}, ""},
|
||||
{"target by id, no offset → conditional",
|
||||
map[string]interface{}{"target-sheet-id": "sht_abc", "source": "'Sheet1'!A1:D100"}, "conditional"},
|
||||
{"target name but source lacks prefix → conditional",
|
||||
map[string]interface{}{"target-sheet-name": "Sheet1", "source": "A1:D100"}, "conditional"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := pivotPlacementWarn(mapFlagView{raw: tc.raw})
|
||||
switch tc.want {
|
||||
case "":
|
||||
if got != "" {
|
||||
t.Errorf("expected no warning, got %q", got)
|
||||
}
|
||||
case "definite":
|
||||
if !strings.Contains(got, "--target-sheet-name") {
|
||||
t.Errorf("expected definite warning citing --target-sheet-name, got %q", got)
|
||||
}
|
||||
case "conditional":
|
||||
if !strings.Contains(got, "a placement sheet is set") {
|
||||
t.Errorf("expected conditional reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetNameFromA1 covers the source-sheet extraction used by the placement
|
||||
// warning: prefix detection, single-quote stripping, and the no-prefix case.
|
||||
func TestSheetNameFromA1(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct{ in, want string }{
|
||||
{"'Sheet1'!A1:D100", "Sheet1"},
|
||||
{"Data!A1", "Data"},
|
||||
{"'My Sheet'!A1:B2", "My Sheet"},
|
||||
{"A1:D100", ""},
|
||||
{"", ""},
|
||||
{" 'X'!A1 ", "X"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := sheetNameFromA1(tc.in); got != tc.want {
|
||||
t.Errorf("sheetNameFromA1(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectCRUDShortcuts_DryRun walks the create / update / delete trio
|
||||
// for each object skill. Together these cover all 21 CRUD shortcuts plus
|
||||
// the per-object id flag renames (rule-id, group-id, view-id, etc.).
|
||||
func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type spec struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}
|
||||
|
||||
tests := []spec{
|
||||
// chart
|
||||
{
|
||||
name: "+chart-create",
|
||||
sc: ChartCreate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
||||
toolName: "manage_chart_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{
|
||||
"type": "line",
|
||||
"position": map[string]interface{}{"row": float64(0), "col": "A"},
|
||||
"size": map[string]interface{}{"width": float64(400), "height": float64(300)},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+chart-update",
|
||||
sc: ChartUpdate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ", "--properties", `{"type":"bar","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
|
||||
toolName: "manage_chart_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "update",
|
||||
"chart_id": "chartXYZ",
|
||||
"properties": map[string]interface{}{
|
||||
"type": "bar",
|
||||
"position": map[string]interface{}{"row": float64(0), "col": "A"},
|
||||
"size": map[string]interface{}{"width": float64(400), "height": float64(300)},
|
||||
},
|
||||
},
|
||||
},
|
||||
// pivot — has extra create flags incl. required --source.
|
||||
// --target-sheet-id is the placement target (where the pivot lands);
|
||||
// the placement selector is renamed from the generic --sheet-id /
|
||||
// --sheet-name to --target-sheet-id / --target-sheet-name to keep
|
||||
// it semantically distinct from the data-source sheet (which is
|
||||
// encoded inside --source as `'SheetName'!Range`).
|
||||
// pivotSpec.allowEmptySheetSelectorOnCreate lets both target
|
||||
// selectors be omitted so the backend auto-creates a sub-sheet —
|
||||
// covered separately in the +pivot-create empty-selector / mutex
|
||||
// tests below.
|
||||
{
|
||||
name: "+pivot-create with placement / source / range flags",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--range", "F1",
|
||||
"--target-position", "B5",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"target_position": "B5",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
"range": "F1",
|
||||
},
|
||||
},
|
||||
},
|
||||
// +pivot-create accepts both target selectors empty — backend
|
||||
// auto-creates a placement sub-sheet.
|
||||
{
|
||||
name: "+pivot-create empty --target-sheet-id / --target-sheet-name omits sheet from input",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+pivot-delete",
|
||||
sc: PivotDelete,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "delete",
|
||||
"pivot_table_id": "ptA",
|
||||
},
|
||||
},
|
||||
// cond-format — --rule-id rename + --rule-type / --ranges hoist.
|
||||
// rule_type lives at properties.rule_type (flat string), not nested
|
||||
// under a `rule` object; enum vocabulary matches server schema
|
||||
// (cellIs / duplicateValues / ... — see mcp-tools.json
|
||||
// manage_conditional_format_object.properties.rule_type).
|
||||
{
|
||||
name: "+cond-format-update id rename + rule-type/ranges",
|
||||
sc: CondFormatUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-id", "ruleA",
|
||||
"--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
|
||||
"--rule-type", "cellIs",
|
||||
"--ranges", `["A1:A100"]`,
|
||||
},
|
||||
toolName: "manage_conditional_format_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "update",
|
||||
"conditional_format_id": "ruleA",
|
||||
"properties": map[string]interface{}{
|
||||
"rule_type": "cellIs",
|
||||
"attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}},
|
||||
"style": map[string]interface{}{"back_color": "#FFD7D7"},
|
||||
"ranges": []interface{}{"A1:A100"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// filter — special, no id flag
|
||||
{
|
||||
name: "+filter-create without --properties sends properties.range only",
|
||||
sc: FilterCreate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[]}`},
|
||||
toolName: "manage_filter_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{
|
||||
"range": "A1:F1000",
|
||||
"rules": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+filter-create with --properties merges rules",
|
||||
sc: FilterCreate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:F1000", "--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`},
|
||||
toolName: "manage_filter_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"properties": map[string]interface{}{
|
||||
"range": "A1:F1000",
|
||||
"rules": []interface{}{map[string]interface{}{
|
||||
"column_index": "B",
|
||||
"conditions": []interface{}{map[string]interface{}{
|
||||
"type": "text",
|
||||
"compare_type": "contains",
|
||||
"values": []interface{}{"x"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// +filter-delete has no separate --filter-id flag because the
|
||||
// server contract sets filter_id === sheet_id; the translator
|
||||
// auto-injects filter_id from --sheet-id. update/delete fail
|
||||
// hard when only --sheet-name is given (no mid-call lookup).
|
||||
name: "+filter-delete (sheet-scoped, auto-injects filter_id=sheet_id)",
|
||||
sc: FilterDelete,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "manage_filter_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"filter_id": testSheetID,
|
||||
"operation": "delete",
|
||||
},
|
||||
},
|
||||
{
|
||||
// +filter-update auto-injects filter_id from sheet_id, hoists
|
||||
// --range out of properties, and merges properties.rules.
|
||||
name: "+filter-update auto-injects filter_id, hoists --range",
|
||||
sc: FilterUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:F1000",
|
||||
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"text","compare_type":"contains","values":["x"]}]}]}`,
|
||||
},
|
||||
toolName: "manage_filter_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"filter_id": testSheetID,
|
||||
"operation": "update",
|
||||
"properties": map[string]interface{}{
|
||||
"range": "A1:F1000",
|
||||
"rules": []interface{}{map[string]interface{}{
|
||||
"column_index": "B",
|
||||
"conditions": []interface{}{map[string]interface{}{
|
||||
"type": "text",
|
||||
"compare_type": "contains",
|
||||
"values": []interface{}{"x"},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
// filter-view CRUD (cli-only via callTool)
|
||||
{
|
||||
name: "+filter-view-create",
|
||||
sc: FilterViewCreate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:Z100", "--properties", `{"view_name":"v1"}`},
|
||||
toolName: "manage_filter_view_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{"view_name": "v1", "range": "A1:Z100"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+filter-view-update with --view-id",
|
||||
sc: FilterViewUpdate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "vABC", "--properties", `{"view_name":"renamed"}`},
|
||||
toolName: "manage_filter_view_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"view_id": "vABC",
|
||||
"operation": "update",
|
||||
},
|
||||
},
|
||||
// sparkline --group-id
|
||||
{
|
||||
name: "+sparkline-update --group-id → group_id",
|
||||
sc: SparklineUpdate,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA", "--properties", `{"type":"line"}`},
|
||||
toolName: "manage_sparkline_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"group_id": "grpA",
|
||||
"operation": "update",
|
||||
"properties": map[string]interface{}{"type": "line"},
|
||||
},
|
||||
},
|
||||
{
|
||||
// happy path for the new sparkline_id check: each
|
||||
// properties.sparklines[i] carries sparkline_id, so the
|
||||
// validator passes through cleanly.
|
||||
name: "+sparkline-update properties.sparklines[] with sparkline_id passes",
|
||||
sc: SparklineUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
|
||||
"--properties", `{"sparklines":[{"sparkline_id":"sl1","source":"Sheet1!A1:A10"}]}`,
|
||||
},
|
||||
toolName: "manage_sparkline_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"group_id": "grpA",
|
||||
"operation": "update",
|
||||
"properties": map[string]interface{}{
|
||||
"sparklines": []interface{}{
|
||||
map[string]interface{}{"sparkline_id": "sl1", "source": "Sheet1!A1:A10"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// float-image — fully hoisted to flat flags
|
||||
{
|
||||
name: "+float-image-create with image-token + position/size",
|
||||
sc: FloatImageCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--image-name", "logo.png",
|
||||
"--image-token", "tok_xyz",
|
||||
"--position-row", "2", "--position-col", "D",
|
||||
"--size-width", "300", "--size-height", "200",
|
||||
},
|
||||
toolName: "manage_float_image_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{
|
||||
"image_name": "logo.png",
|
||||
"image_token": "tok_xyz",
|
||||
"position": map[string]interface{}{"row": float64(2), "col": "D"},
|
||||
"size": map[string]interface{}{"width": float64(300), "height": float64(200)},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// patch mode: position + size with no image source. The image
|
||||
// fields are omitted so the server keeps the current image; only
|
||||
// image_name (server-mandated) and the changed geometry are sent.
|
||||
// This is the shape that used to be rejected CLI-side.
|
||||
name: "+float-image-update patch position+size, no image source",
|
||||
sc: FloatImageUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--float-image-id", "imgABC", "--image-name", "logo.png",
|
||||
"--position-row", "10", "--position-col", "I",
|
||||
"--size-width", "90", "--size-height", "70",
|
||||
},
|
||||
toolName: "manage_float_image_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "update",
|
||||
"float_image_id": "imgABC",
|
||||
"properties": map[string]interface{}{
|
||||
"image_name": "logo.png",
|
||||
"position": map[string]interface{}{"row": float64(10), "col": "I"},
|
||||
"size": map[string]interface{}{"width": float64(90), "height": float64(70)},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// swap the image: an explicit --image-token rides alongside the
|
||||
// mandatory core (image_name + position + size).
|
||||
name: "+float-image-update swap image via image-token",
|
||||
sc: FloatImageUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--float-image-id", "imgABC",
|
||||
"--image-name", "new.png", "--image-token", "tok_new",
|
||||
"--position-row", "2", "--position-col", "B",
|
||||
"--size-width", "300", "--size-height", "200",
|
||||
},
|
||||
toolName: "manage_float_image_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "update",
|
||||
"float_image_id": "imgABC",
|
||||
"properties": map[string]interface{}{
|
||||
"image_name": "new.png",
|
||||
"image_token": "tok_new",
|
||||
"position": map[string]interface{}{"row": float64(2), "col": "B"},
|
||||
"size": map[string]interface{}{"width": float64(300), "height": float64(200)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPivotCreate_SheetSelectorSemantics locks in the "at most one"
|
||||
// semantics for +pivot-create (and only +pivot-create): both
|
||||
// --target-sheet-id and --target-sheet-name may be omitted (backend
|
||||
// auto-creates a placement sub-sheet), but passing both is rejected.
|
||||
//
|
||||
// Companion regression — TestObjectCreate_RequiresSheetSelector below —
|
||||
// confirms every other *-create still rejects empty selector.
|
||||
func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("both empty is accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
if _, ok := input["sheet_id"]; ok {
|
||||
t.Errorf("expected no sheet_id in input; got %v", input["sheet_id"])
|
||||
}
|
||||
if _, ok := input["sheet_name"]; ok {
|
||||
t.Errorf("expected no sheet_name in input; got %v", input["sheet_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("both set is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--target-sheet-name", "Sheet1",
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
// 错误信息必须用真实的 flag 名(target-*),否则模型按消息提示去
|
||||
// 改 --sheet-id 还是错的。
|
||||
if !strings.Contains(combined, "--target-sheet-id") {
|
||||
t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only target-sheet-id is accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
if got, _ := input["sheet_id"].(string); got != testSheetID {
|
||||
t.Errorf("sheet_id = %q, want %q", got, testSheetID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_SchemaValidates exercises the schema-driven
|
||||
// validator wired into objectCreateInput. The pivot create schema
|
||||
// doesn't constrain rows/columns/values to be present (the backend
|
||||
// just creates an empty shell), but it does pin types and enums —
|
||||
// confirm both kinds of misuse are surfaced as CLI-side errors and
|
||||
// that schema-conformant input is accepted.
|
||||
func TestPivotCreate_SchemaValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("rejects wrong type for rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"rows":"not-an-array"}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") {
|
||||
t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects out-of-enum summarize_by", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr+err.Error(), "summarize_by") {
|
||||
t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema-conformant input is accepted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"values":[{"field":"A","summarize_by":"sum"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
})
|
||||
decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
})
|
||||
}
|
||||
|
||||
// TestObjectCreate_RequiresSheetSelector regresses the non-pivot create
|
||||
// shortcuts: pivot-create is the only one whose spec sets
|
||||
// allowEmptySheetSelectorOnCreate=true. Every other *-create must still
|
||||
// reject empty --sheet-id / --sheet-name (this is the guardrail that
|
||||
// keeps the change minimally scoped).
|
||||
func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string // omit sheet selector flags on purpose
|
||||
}{
|
||||
{"chart", ChartCreate, []string{"--url", testURL, "--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`}},
|
||||
{"cond-format", CondFormatCreate, []string{"--url", testURL, "--properties", `{"attrs":[]}`, "--rule-type", "cellIs", "--ranges", `["A1:A10"]`}},
|
||||
{"sparkline", SparklineCreate, []string{"--url", testURL, "--properties", `{"sparklines":[]}`}},
|
||||
{"filter-view", FilterViewCreate, []string{"--url", testURL, "--properties", `{}`, "--range", "A1:F10"}},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
|
||||
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSparklineUpdate_MissingSparklineID confirms the standalone-path
|
||||
// pre-check fires: +sparkline-update with properties.sparklines[] but no
|
||||
// per-item sparkline_id must fail CLI-side with a pointer to
|
||||
// +sparkline-list, before any server call goes out.
|
||||
func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
|
||||
"--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "missing sparkline_id") {
|
||||
t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err)
|
||||
}
|
||||
if !strings.Contains(combined, "+sparkline-list") {
|
||||
t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: +float-image-update's image_name / position / size are cobra-required
|
||||
// (flag-defs.json), so the standalone path is gated by the flag layer — its
|
||||
// "required flag(s) … not set" wording is framework-owned and intentionally not
|
||||
// re-asserted here. The CLI-side enforcement that matters is on the
|
||||
// +batch-update sub-op path (no cobra layer); that is covered by
|
||||
// TestBatchOp_RejectsBadSubOpInput in batch_op_contract_test.go.
|
||||
|
||||
// TestFloatImageCreate_RequiresImageSource guards the asymmetry with update:
|
||||
// create still mandates one of --image / --image-token / --image-uri.
|
||||
func TestFloatImageCreate_RequiresImageSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--image-name", "x.png",
|
||||
"--position-row", "0", "--position-col", "A",
|
||||
"--size-width", "10", "--size-height", "10",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr)
|
||||
}
|
||||
if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") {
|
||||
t.Errorf("expected error to require an image source; got=%s|%v", stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks
|
||||
// without --yes (framework-enforced).
|
||||
func TestObjectDelete_AllHighRisk(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{"chart", ChartDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "x"}},
|
||||
{"pivot", PivotDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "x"}},
|
||||
{"cond-format", CondFormatDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "x"}},
|
||||
{"filter", FilterDelete, []string{"--url", testURL, "--sheet-id", testSheetID}},
|
||||
{"filter-view", FilterViewDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "x"}},
|
||||
{"sparkline", SparklineDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "x"}},
|
||||
{"float-image", FloatImageDelete, []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "x"}},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
|
||||
t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
157
shortcuts/sheets/lark_sheet_object_list.go
Normal file
157
shortcuts/sheets/lark_sheet_object_list.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── object list shortcuts ────────────────────────────────────────────
|
||||
//
|
||||
// Seven object-collection skills each expose a single "list" read shortcut
|
||||
// that lives next to their CRUD siblings (chart / pivot / cond-format /
|
||||
// filter / filter-view / sparkline / float-image). All seven share the
|
||||
// exact same shape — public sheet selector + optional --<id> filter — so
|
||||
// they're declared via newObjectListShortcut.
|
||||
//
|
||||
// +filter-view-list is `cli_status: cli-only`, but the underlying tool
|
||||
// get_filter_view_objects is in mcp-tools.json and dispatches through the
|
||||
// same One-OpenAPI endpoint as everything else; no special path needed.
|
||||
|
||||
// objectListSpec describes a single list-style read shortcut.
|
||||
type objectListSpec struct {
|
||||
command string // CLI command, e.g. "+chart-list"
|
||||
description string // one-liner for --help
|
||||
toolName string // MCP tool name, e.g. "get_chart_objects"
|
||||
|
||||
// Optional id filter. Empty filterFlag → no filter flag exposed.
|
||||
filterFlag string // CLI flag name (without leading --), e.g. "chart-id"
|
||||
filterField string // tool input key, e.g. "chart_id"
|
||||
}
|
||||
|
||||
func newObjectListShortcut(spec objectListSpec) common.Shortcut {
|
||||
flags := flagsFor(spec.command)
|
||||
return common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: spec.command,
|
||||
Description: spec.description,
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flags,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := resolveSheetSelector(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func objectListInput(runtime *common.RuntimeContext, token, sheetID, sheetName string, spec objectListSpec) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if spec.filterFlag != "" {
|
||||
if v := strings.TrimSpace(runtime.Str(spec.filterFlag)); v != "" {
|
||||
input[spec.filterField] = v
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// ─── shortcut declarations ────────────────────────────────────────────
|
||||
|
||||
// ChartList — list charts on a sheet (optionally filtered to one chart_id).
|
||||
var ChartList = newObjectListShortcut(objectListSpec{
|
||||
command: "+chart-list",
|
||||
description: "List charts on a sheet, optionally filtered to a single chart_id.",
|
||||
toolName: "get_chart_objects",
|
||||
filterFlag: "chart-id",
|
||||
filterField: "chart_id",
|
||||
})
|
||||
|
||||
// PivotList — list pivot tables on a sheet.
|
||||
var PivotList = newObjectListShortcut(objectListSpec{
|
||||
command: "+pivot-list",
|
||||
description: "List pivot tables on a sheet, optionally filtered to a single pivot_table_id.",
|
||||
toolName: "get_pivot_table_objects",
|
||||
filterFlag: "pivot-table-id",
|
||||
filterField: "pivot_table_id",
|
||||
})
|
||||
|
||||
// CondFormatList — list conditional format rules. CLI's --rule-id maps to
|
||||
// the tool's conditional_format_id (CLI uses the shorter common term).
|
||||
var CondFormatList = newObjectListShortcut(objectListSpec{
|
||||
command: "+cond-format-list",
|
||||
description: "List conditional format rules on a sheet, optionally filtered to a single rule.",
|
||||
toolName: "get_conditional_format_objects",
|
||||
filterFlag: "rule-id",
|
||||
filterField: "conditional_format_id",
|
||||
})
|
||||
|
||||
// FilterList — list active sheet-level filters. No id filter because each
|
||||
// sheet carries at most one filter.
|
||||
var FilterList = newObjectListShortcut(objectListSpec{
|
||||
command: "+filter-list",
|
||||
description: "List active sheet-level filters across the workbook (or one sheet).",
|
||||
toolName: "get_filter_objects",
|
||||
})
|
||||
|
||||
// FilterViewList — list filter views on a sheet. `cli-only` skill (not
|
||||
// exposed as MCP tool catalog), but the tool itself is dispatched through
|
||||
// the same One-OpenAPI endpoint.
|
||||
var FilterViewList = newObjectListShortcut(objectListSpec{
|
||||
command: "+filter-view-list",
|
||||
description: "List filter views on a sheet, optionally filtered to a single view_id.",
|
||||
toolName: "get_filter_view_objects",
|
||||
filterFlag: "view-id",
|
||||
filterField: "view_id",
|
||||
})
|
||||
|
||||
// SparklineList — list sparkline groups on a sheet. The tool also accepts
|
||||
// a per-sparkline id (`sparkline_id`); CLI exposes the higher-level
|
||||
// --group-id which is what callers usually care about.
|
||||
var SparklineList = newObjectListShortcut(objectListSpec{
|
||||
command: "+sparkline-list",
|
||||
description: "List sparkline groups on a sheet, optionally filtered by group_id.",
|
||||
toolName: "get_sparkline_objects",
|
||||
filterFlag: "group-id",
|
||||
filterField: "group_id",
|
||||
})
|
||||
|
||||
// FloatImageList — list floating images on a sheet (vs. embedded
|
||||
// cell-images which live in cell metadata).
|
||||
var FloatImageList = newObjectListShortcut(objectListSpec{
|
||||
command: "+float-image-list",
|
||||
description: "List floating images on a sheet, optionally filtered to a single float_image_id.",
|
||||
toolName: "get_float_image_objects",
|
||||
filterFlag: "float-image-id",
|
||||
filterField: "float_image_id",
|
||||
})
|
||||
111
shortcuts/sheets/lark_sheet_object_list_test.go
Normal file
111
shortcuts/sheets/lark_sheet_object_list_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestObjectListShortcuts_DryRun covers all 7 object-list shortcuts.
|
||||
// Each spec asserts the tool name + that the optional filter flag maps
|
||||
// to the right tool field (including the --rule-id → conditional_format_id
|
||||
// rename).
|
||||
func TestObjectListShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+chart-list no filter",
|
||||
sc: ChartList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "get_chart_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+chart-list with filter",
|
||||
sc: ChartList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chartXYZ"},
|
||||
toolName: "get_chart_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"chart_id": "chartXYZ",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+pivot-list filter",
|
||||
sc: PivotList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--pivot-table-id", "ptA"},
|
||||
toolName: "get_pivot_table_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"pivot_table_id": "ptA",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cond-format-list --rule-id → conditional_format_id",
|
||||
sc: CondFormatList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA"},
|
||||
toolName: "get_conditional_format_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"conditional_format_id": "ruleA",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+filter-list (no filter flag) by sheet-name",
|
||||
sc: FilterList,
|
||||
args: []string{"--url", testURL, "--sheet-name", "Sheet1"},
|
||||
toolName: "get_filter_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_name": "Sheet1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+filter-view-list cli-only via callTool",
|
||||
sc: FilterViewList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--view-id", "viewABC"},
|
||||
toolName: "get_filter_view_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"view_id": "viewABC",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sparkline-list --group-id",
|
||||
sc: SparklineList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA"},
|
||||
toolName: "get_sparkline_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"group_id": "grpA",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+float-image-list",
|
||||
sc: FloatImageList,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--float-image-id", "imgA"},
|
||||
toolName: "get_float_image_objects",
|
||||
wantInput: map[string]interface{}{
|
||||
"float_image_id": "imgA",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
665
shortcuts/sheets/lark_sheet_range_operations.go
Normal file
665
shortcuts/sheets/lark_sheet_range_operations.go
Normal file
@@ -0,0 +1,665 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_range_operations ──────────────────────────────────────
|
||||
//
|
||||
// Four tools, nine shortcuts:
|
||||
//
|
||||
// - clear_cell_range → +cells-clear (high-risk-write)
|
||||
// - merge_cells → +cells-merge / +cells-unmerge
|
||||
// - resize_range → +rows-resize / +cols-resize
|
||||
// - transform_range → +range-move / +range-copy / +range-fill / +range-sort
|
||||
//
|
||||
// +rows-resize / +cols-resize are grouped under "工作表" for CLI discoverability
|
||||
// even though the backing tool lives in this skill.
|
||||
|
||||
// CellsClear wraps clear_cell_range.
|
||||
//
|
||||
// CLI's --scope vocabulary (content / formats / all) is normalized to the
|
||||
// tool's clear_type vocabulary (contents / formats / all) — the spec's
|
||||
// singular/plural mismatch is intentionally absorbed here.
|
||||
var CellsClear = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-clear",
|
||||
Description: "Clear cell content, formats, or both within a range (irreversible).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-clear"),
|
||||
Validate: validateViaInput(cellsClearInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := cellsClearInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := cellsClearInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "clear_cell_range", input)
|
||||
if err != nil {
|
||||
return annotateEmbeddedBlockClearErr(err)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"high-risk-write — always preview with --dry-run; clear is not undoable.",
|
||||
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
|
||||
},
|
||||
}
|
||||
|
||||
func cellsClearInput(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, common.FlagErrorf("--range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"clear_type": normalizeClearType(runtime.Str("scope")),
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// normalizeClearType maps the CLI --scope vocabulary (content / formats / all)
|
||||
// to the clear_cell_range tool's clear_type vocabulary (contents / formats /
|
||||
// all). The content↔contents singular/plural mismatch is absorbed here so both
|
||||
// +cells-clear and the +cells-batch-clear fan-out stay in lockstep.
|
||||
func normalizeClearType(scope string) string {
|
||||
switch scope {
|
||||
case "formats", "all":
|
||||
return scope
|
||||
default: // "content" or unset
|
||||
return "contents"
|
||||
}
|
||||
}
|
||||
|
||||
// annotateEmbeddedBlockClearErr augments the backend's "embedded block" clear
|
||||
// failure with the concrete fix. clear_cell_range only clears cell values /
|
||||
// formats — it cannot delete an embedded object (pivot table / chart) that
|
||||
// overlaps the range, which is what the backend's "can not find embedded block"
|
||||
// actually means. Trajectories burned dozens of commands trying to recover a
|
||||
// pivot-occupied A1 with cells-clear; point the agent at the object's own
|
||||
// delete command instead. Non-matching errors pass through untouched.
|
||||
func annotateEmbeddedBlockClearErr(err error) error {
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) || ee.Detail == nil {
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(ee.Detail.Message), "embedded block") {
|
||||
return err
|
||||
}
|
||||
const hint = "the range overlaps an embedded object (pivot table / chart); " +
|
||||
"cells-clear only clears cell values/formats and cannot delete it — " +
|
||||
"delete the object with its own command (+pivot-delete / +chart-delete; find the id via +pivot-list / +chart-list)"
|
||||
if ee.Detail.Hint == "" {
|
||||
ee.Detail.Hint = hint
|
||||
} else {
|
||||
ee.Detail.Hint += "; " + hint
|
||||
}
|
||||
return ee
|
||||
}
|
||||
|
||||
// CellsMerge / CellsUnmerge share the merge_cells tool, dispatched by the
|
||||
// `operation` enum. --merge-type applies to merge only and maps to tool
|
||||
// field merge_type (`all` / `rows` / `columns`).
|
||||
var CellsMerge = newMergeShortcut(
|
||||
"+cells-merge", "Merge cells in a range.", "merge", true,
|
||||
)
|
||||
var CellsUnmerge = newMergeShortcut(
|
||||
"+cells-unmerge", "Unmerge cells in a range.", "unmerge", false,
|
||||
)
|
||||
|
||||
func newMergeShortcut(command, desc, op string, withMergeType bool) common.Shortcut {
|
||||
flags := flagsFor(command)
|
||||
return common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: command,
|
||||
Description: desc,
|
||||
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 = mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := mergeInput(runtime, token, sheetID, sheetName, op, withMergeType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "merge_cells", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMergeType bool) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"operation": op,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if withMergeType {
|
||||
if mt := runtime.Str("merge-type"); mt != "" && mt != "all" {
|
||||
input["merge_type"] = mt
|
||||
} else {
|
||||
input["merge_type"] = "all"
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// resize_range exposes two CLI shortcuts:
|
||||
//
|
||||
// +rows-resize / +cols-resize — set row heights / column widths. --type
|
||||
// enum (pixel / standard / [auto]) controls how: --type pixel needs --size,
|
||||
// --type standard restores the sheet default, --type auto auto-fits row
|
||||
// heights (rows only). --range is an A1 closed range ("2:10" / "5" rows or
|
||||
// "A:E" / "C" columns); single-element form is expanded to "N:N" before
|
||||
// send because resize_range rejects bare single-element ranges.
|
||||
//
|
||||
// Wire shape: resize_height / resize_width carries { type, value? }, e.g.
|
||||
// { "type": "pixel", "value": 30 } or { "type": "standard" }.
|
||||
|
||||
// RowsResize wraps resize_range for row heights. --type auto enables
|
||||
// auto-fit (rows only); --type pixel requires --size.
|
||||
var RowsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+rows-resize",
|
||||
Description: "Resize rows by pixel / standard / auto (--type pixel needs --size; --range is 1-based A1 like \"2:10\" or \"5\").",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+rows-resize"),
|
||||
Validate: validateViaResize("row"),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := resizeInput(runtime, token, sheetID, sheetName, "row")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, "row")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ColsResize wraps resize_range for column widths. Column widths do not
|
||||
// support auto-fit — --type only accepts pixel / standard.
|
||||
var ColsResize = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cols-resize",
|
||||
Description: "Resize columns by pixel / standard (--type pixel needs --size; --range is column letters like \"A:E\" or \"C\"; no auto for cols).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cols-resize"),
|
||||
Validate: validateViaResize("column"),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := resizeInput(runtime, token, sheetID, sheetName, "column")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := resizeInput(runtime, token, sheetID, sheetName, "column")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "resize_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateViaResize wires the standalone Validate to resizeInput so both
|
||||
// paths (standalone + batch sub-op) emit the same error for missing --type,
|
||||
// malformed --range, or --type auto on columns.
|
||||
func validateViaResize(dimension string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return 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 = resizeInput(runtime, token, sheetID, sheetName, dimension)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// autoSuffix appends " / auto" to the enum hint for rows.
|
||||
func autoSuffix(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return " / auto"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// commandForDimension returns the shortcut command name a given dimension
|
||||
// belongs to; used in error messages so users see "+rows-resize" / "+cols-resize"
|
||||
// instead of the internal "row" / "column" tag.
|
||||
func commandForDimension(dimension string) string {
|
||||
if dimension == "row" {
|
||||
return "+rows-resize"
|
||||
}
|
||||
return "+cols-resize"
|
||||
}
|
||||
|
||||
// resizeInput builds the resize_range tool input. dimension is "row" /
|
||||
// "column" (selected by the calling shortcut); --range must match that
|
||||
// dimension (row → digits like "2:10" / "5"; column → letters like "A:E" /
|
||||
// "C"). Single-element form is expanded to "N:N" because resize_range
|
||||
// rejects bare single-element ranges.
|
||||
func resizeInput(runtime flagView, token, sheetID, sheetName, dimension string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("range") {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
parsedDim, _, _, err := parseA1Range(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
}
|
||||
if parsedDim != dimension {
|
||||
want := "row numbers (e.g. \"2:10\")"
|
||||
if dimension == "column" {
|
||||
want = "column letters (e.g. \"A:E\")"
|
||||
}
|
||||
return nil, common.FlagErrorf("--range %q is a %s range; %s expects %s", rangeStr, parsedDim, commandForDimension(dimension), want)
|
||||
}
|
||||
if !strings.Contains(rangeStr, ":") {
|
||||
rangeStr = rangeStr + ":" + rangeStr
|
||||
}
|
||||
typ := strings.TrimSpace(runtime.Str("type"))
|
||||
if typ == "" {
|
||||
return nil, common.FlagErrorf("--type is required (pixel / standard%s)", autoSuffix(dimension))
|
||||
}
|
||||
if dimension == "column" && typ == "auto" {
|
||||
return nil, common.FlagErrorf("--type auto is rows-only (column widths do not support auto-fit); use +rows-resize")
|
||||
}
|
||||
hasSize := runtime.Changed("size") && runtime.Int("size") > 0
|
||||
if typ == "pixel" && !hasSize {
|
||||
return nil, common.FlagErrorf("--type pixel requires --size <px>")
|
||||
}
|
||||
if typ != "pixel" && hasSize {
|
||||
return nil, common.FlagErrorf("--size is only valid with --type pixel")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": rangeStr,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
sizeBlock := map[string]interface{}{"type": typ}
|
||||
if typ == "pixel" {
|
||||
sizeBlock["value"] = runtime.Int("size")
|
||||
}
|
||||
if dimension == "row" {
|
||||
input["resize_height"] = sizeBlock
|
||||
} else {
|
||||
input["resize_width"] = sizeBlock
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ─── transform_range (4 shortcuts) ────────────────────────────────────
|
||||
//
|
||||
// move / copy take --source-range + --target-range (+ optional cross-sheet
|
||||
// target). fill takes --source-range + --target-range + --series-type. sort
|
||||
// takes --range + --sort-keys + --has-header.
|
||||
|
||||
// RangeMove cuts data from --source-range and pastes at --target-range,
|
||||
// optionally on another sheet.
|
||||
var RangeMove = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+range-move",
|
||||
Description: "Cut a range and paste it at a new location (optionally cross-sheet).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+range-move"),
|
||||
Validate: validateRangeMoveOrCopy("move", false),
|
||||
DryRun: transformDryRunFn("move", false, false),
|
||||
Execute: transformExecuteFn("move", false, false),
|
||||
}
|
||||
|
||||
// RangeCopy duplicates a range to a new location with optional paste-type
|
||||
// filter (values / formulas / formats / all).
|
||||
var RangeCopy = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+range-copy",
|
||||
Description: "Copy a range to a new location (--paste-type controls what is copied).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+range-copy"),
|
||||
Validate: validateRangeMoveOrCopy("copy", true),
|
||||
DryRun: transformDryRunFn("copy", true, false),
|
||||
Execute: transformExecuteFn("copy", true, false),
|
||||
}
|
||||
|
||||
// RangeFill performs autofill from a template range into a target range.
|
||||
// --series-type is a 5-value CLI vocabulary; the tool only distinguishes
|
||||
// `copyCells` from `fillSeries`. The mapping is documented in
|
||||
// fillSeriesToToolType.
|
||||
var RangeFill = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+range-fill",
|
||||
Description: "Autofill a target range from a source template (copy / linear / growth / date series).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+range-fill"),
|
||||
Validate: validateViaInput(rangeFillInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := rangeFillInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := rangeFillInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// RangeSort sorts rows within a range by one or more columns.
|
||||
var RangeSort = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+range-sort",
|
||||
Description: "Sort rows within a range by one or more columns.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+range-sort"),
|
||||
Validate: validateViaInput(rangeSortInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := rangeSortInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := rangeSortInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ─── transform_range helpers ──────────────────────────────────────────
|
||||
|
||||
// validateRangeMoveOrCopy wires the standalone Validate to transformMoveCopyInput
|
||||
// so missing --source-range / --target-range fire the same friendly error on
|
||||
// the batch sub-op path.
|
||||
func validateRangeMoveOrCopy(op string, withPasteType bool) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return 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 = transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) *common.DryRunAPI {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
}
|
||||
}
|
||||
|
||||
func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := transformMoveCopyInput(runtime, token, sheetID, sheetName, op, withPasteType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "transform_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func transformMoveCopyInput(runtime flagView, token, sheetID, sheetName, op string, withPasteType bool) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("source-range")) == "" {
|
||||
return nil, common.FlagErrorf("--source-range is required")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("target-range")) == "" {
|
||||
return nil, common.FlagErrorf("--target-range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": op,
|
||||
"range": strings.TrimSpace(runtime.Str("source-range")),
|
||||
"destination_range": strings.TrimSpace(runtime.Str("target-range")),
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if tgt := strings.TrimSpace(runtime.Str("target-sheet-id")); tgt != "" {
|
||||
input["destination_sheet_id"] = tgt
|
||||
}
|
||||
if withPasteType {
|
||||
if pt := runtime.Str("paste-type"); pt != "" && pt != "all" {
|
||||
input["paste_type"] = pasteTypeToTool(pt)
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// pasteTypeToTool maps the CLI vocabulary (values / formulas / formats / all)
|
||||
// to the tool's paste_type field (all / value_only / formula_only / format_only).
|
||||
func pasteTypeToTool(pt string) string {
|
||||
switch pt {
|
||||
case "values":
|
||||
return "value_only"
|
||||
case "formulas":
|
||||
return "formula_only"
|
||||
case "formats":
|
||||
return "format_only"
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
func rangeFillInput(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("source-range")) == "" {
|
||||
return nil, common.FlagErrorf("--source-range is required")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("target-range")) == "" {
|
||||
return nil, common.FlagErrorf("--target-range is required")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "fill",
|
||||
"range": strings.TrimSpace(runtime.Str("source-range")),
|
||||
"destination_range": strings.TrimSpace(runtime.Str("target-range")),
|
||||
"fill_type": fillSeriesToToolType(runtime.Str("series-type")),
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// fillSeriesToToolType maps the CLI series vocabulary to the tool's fill_type.
|
||||
// The tool only distinguishes copy vs series; the CLI's series flavor (linear /
|
||||
// growth / date / auto) all collapse to fillSeries — the actual progression is
|
||||
// inferred by the server from the source cells.
|
||||
func fillSeriesToToolType(seriesType string) string {
|
||||
if seriesType == "copy" {
|
||||
return "copyCells"
|
||||
}
|
||||
return "fillSeries"
|
||||
}
|
||||
|
||||
func rangeSortInput(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, common.FlagErrorf("--range is required")
|
||||
}
|
||||
// requireJSONArray runs the embedded JSON Schema for --sort-keys
|
||||
// via parseJSONFlag → validateParsedJSONFlag, so each item is
|
||||
// already pinned to {column: string, ascending: bool} with the
|
||||
// failing index reported. No per-item hand-written guard needed.
|
||||
keys, err := requireJSONArray(runtime, "sort-keys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "sort",
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"sort_conditions": keys,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if runtime.Bool("has-header") {
|
||||
input["has_header"] = true
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
360
shortcuts/sheets/lark_sheet_range_operations_test.go
Normal file
360
shortcuts/sheets/lark_sheet_range_operations_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestAnnotateEmbeddedBlockClearErr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("adds pivot-delete hint on embedded-block error", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
|
||||
Type: "api",
|
||||
Message: `tool "clear_cell_range" failed: [500] can not find embedded block`,
|
||||
}}
|
||||
var ee *output.ExitError
|
||||
if !errors.As(annotateEmbeddedBlockClearErr(in), &ee) || ee.Detail == nil {
|
||||
t.Fatal("expected ExitError with detail")
|
||||
}
|
||||
if !strings.Contains(ee.Detail.Hint, "+pivot-delete") {
|
||||
t.Errorf("hint should point at +pivot-delete, got %q", ee.Detail.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends to existing hint", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{
|
||||
Message: "embedded block missing", Hint: "preexisting",
|
||||
}}
|
||||
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
|
||||
if !strings.HasPrefix(out.Detail.Hint, "preexisting; ") {
|
||||
t.Errorf("existing hint should be preserved and appended, got %q", out.Detail.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes through unrelated ExitError untouched", func(t *testing.T) {
|
||||
in := &output.ExitError{Code: output.ExitAPI, Detail: &output.ErrDetail{Message: "some other failure"}}
|
||||
out := annotateEmbeddedBlockClearErr(in).(*output.ExitError)
|
||||
if out.Detail.Hint != "" {
|
||||
t.Errorf("unrelated error should not gain a hint, got %q", out.Detail.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes through non-ExitError untouched", func(t *testing.T) {
|
||||
in := errors.New("can not find embedded block")
|
||||
if out := annotateEmbeddedBlockClearErr(in); out != in {
|
||||
t.Error("plain (non-ExitError) error should be returned as-is")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRangeOperationsShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+cells-clear scope=content → clear_type=contents",
|
||||
sc: CellsClear,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "content"},
|
||||
toolName: "clear_cell_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C5",
|
||||
"clear_type": "contents",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-clear scope=all passthrough",
|
||||
sc: CellsClear,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C5", "--scope", "all"},
|
||||
toolName: "clear_cell_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"clear_type": "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-merge with merge-type",
|
||||
sc: CellsMerge,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--merge-type", "rows"},
|
||||
toolName: "merge_cells",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:B2",
|
||||
"operation": "merge",
|
||||
"merge_type": "rows",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-unmerge (no merge-type flag)",
|
||||
sc: CellsUnmerge,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"},
|
||||
toolName: "merge_cells",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:B2",
|
||||
"operation": "unmerge",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --range 1:5 pixel 200",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel", "--size", "200"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "1:5",
|
||||
"resize_height": map[string]interface{}{
|
||||
"type": "pixel",
|
||||
"value": float64(200),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+rows-resize single row \"1\" expands to \"1:1\"",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1", "--type", "auto"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "1:1",
|
||||
"resize_height": map[string]interface{}{"type": "auto"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --range B:D standard",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D", "--type", "standard"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "B:D",
|
||||
"resize_width": map[string]interface{}{
|
||||
"type": "standard",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize --range A:C pixel 120",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "pixel", "--size", "120"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "A:C",
|
||||
"resize_width": map[string]interface{}{
|
||||
"type": "pixel",
|
||||
"value": float64(120),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cols-resize single column \"C\" expands to \"C:C\"",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C", "--type", "standard"},
|
||||
toolName: "resize_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"range": "C:C",
|
||||
"resize_width": map[string]interface{}{"type": "standard"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+range-move cross-sheet",
|
||||
sc: RangeMove,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "D1", "--target-sheet-id", testSheetID2},
|
||||
toolName: "transform_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "move",
|
||||
"range": "A1:C5",
|
||||
"destination_range": "D1",
|
||||
"destination_sheet_id": testSheetID2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+range-copy paste-type values → value_only",
|
||||
sc: RangeCopy,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1", "--paste-type", "values"},
|
||||
toolName: "transform_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "copy",
|
||||
"range": "A1:C5",
|
||||
"destination_range": "E1",
|
||||
"paste_type": "value_only",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+range-copy paste-type all → field omitted",
|
||||
sc: RangeCopy,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:C5", "--target-range", "E1"},
|
||||
toolName: "transform_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "copy",
|
||||
"range": "A1:C5",
|
||||
"destination_range": "E1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+range-fill series=copy → copyCells",
|
||||
sc: RangeFill,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "copy"},
|
||||
toolName: "transform_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "fill",
|
||||
"range": "A1:A3",
|
||||
"destination_range": "A4:A10",
|
||||
"fill_type": "copyCells",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+range-fill series=linear → fillSeries",
|
||||
sc: RangeFill,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--source-range", "A1:A3", "--target-range", "A4:A10", "--series-type", "linear"},
|
||||
toolName: "transform_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"fill_type": "fillSeries",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+range-sort multi-key with header",
|
||||
sc: RangeSort,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:E100", "--has-header", "--sort-keys", `[{"column":"B","ascending":true},{"column":"D","ascending":false}]`},
|
||||
toolName: "transform_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "sort",
|
||||
"range": "A1:E100",
|
||||
"has_header": true,
|
||||
"sort_conditions": []interface{}{
|
||||
map[string]interface{}{"column": "B", "ascending": true},
|
||||
map[string]interface{}{"column": "D", "ascending": false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRangeSort_RejectsMalformedKeys verifies the schema-driven check
|
||||
// that each --sort-keys entry has both `column` (string) and
|
||||
// `ascending` (bool). The schema validator (loaded from
|
||||
// data/flag-schemas.json) reports the offending JSON path; previously
|
||||
// the CLI passed any JSON through and the server bounced with a terse
|
||||
// "required property X missing" that didn't name the bad entry.
|
||||
func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
keys string
|
||||
want string
|
||||
}{
|
||||
{"missing column", `[{"ascending":true}]`, `required property "column" is missing at [0]`},
|
||||
{"missing ascending", `[{"column":"B"}]`, `required property "ascending" is missing at [0]`},
|
||||
{"old vocab col/order", `[{"col":"B","order":"asc"}]`, `required property "column" is missing at [0]`},
|
||||
{"non-object item", `["B"]`, `[0]: expected type "object"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
|
||||
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "+rows-resize --type pixel without --size",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "pixel"},
|
||||
want: "--type pixel requires --size",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize --type standard with --size",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard", "--size", "30"},
|
||||
want: "--size is only valid with --type pixel",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize rejects --type auto",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "auto"},
|
||||
want: "auto", // cobra Enum gate kicks first with "valid values are: pixel, standard"
|
||||
},
|
||||
{
|
||||
name: "+rows-resize given column range",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A:C", "--type", "standard"},
|
||||
want: "+rows-resize expects row numbers",
|
||||
},
|
||||
{
|
||||
name: "+cols-resize given row range",
|
||||
sc: ColsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--type", "standard"},
|
||||
want: "+cols-resize expects column letters",
|
||||
},
|
||||
{
|
||||
name: "+rows-resize end < start",
|
||||
sc: RowsResize,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--type", "standard"},
|
||||
want: "end position is before start",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
523
shortcuts/sheets/lark_sheet_read_data.go
Normal file
523
shortcuts/sheets/lark_sheet_read_data.go
Normal file
@@ -0,0 +1,523 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_read_data ─────────────────────────────────────────────
|
||||
//
|
||||
// Wraps:
|
||||
// - get_cell_ranges (powers +cells-get and +dropdown-get)
|
||||
// - get_range_as_csv (powers +csv-get)
|
||||
//
|
||||
// The sandbox tool (export_sheet_to_sandbox) is Sheet-Tool-only and has no
|
||||
// CLI surface here.
|
||||
|
||||
// unboundedReadLimit is pinned into the tool's cell_limit / max_rows so that
|
||||
// --max-chars is the single effective read cap. The underlying tools default
|
||||
// those two to smaller values; without an explicit high value they could
|
||||
// truncate before max_chars. The CLI no longer exposes --cell-limit / --max-rows
|
||||
// (only --max-chars), so we pass this sentinel to neutralize the tool defaults.
|
||||
// Large enough to never bind on any real sheet.
|
||||
const unboundedReadLimit = 1_000_000_000
|
||||
|
||||
// CellsGet wraps get_cell_ranges: read multiple A1 ranges and return per-cell
|
||||
// values, formulas, styles, and other metadata as requested via --include.
|
||||
var CellsGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-get",
|
||||
Description: "Read one or more cell ranges with values, formulas, and optional styles / comments / data validation.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"ranges": []string{strings.TrimSpace(runtime.Str("range"))},
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
applyIncludeToCellsGet(input, runtime.StrSlice("include"))
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
}
|
||||
// --cell-limit was removed from the CLI surface; --max-chars is the single
|
||||
// read cap. Pin cell_limit very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["cell_limit"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
|
||||
// tool's two coarse switches:
|
||||
//
|
||||
// - include_styles (bool) — toggled by "style" presence
|
||||
// - value_render_option (enum) — "formula" → formula; otherwise omitted
|
||||
//
|
||||
// "value", "comment", and "data_validation" are always returned by the tool
|
||||
// per the schema; they have no dedicated knob today but are accepted in
|
||||
// --include for forward-compat with finer-grained server support.
|
||||
func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
|
||||
if len(include) == 0 {
|
||||
return
|
||||
}
|
||||
want := map[string]bool{}
|
||||
for _, v := range include {
|
||||
want[v] = true
|
||||
}
|
||||
if want["style"] {
|
||||
input["include_styles"] = true
|
||||
} else {
|
||||
input["include_styles"] = false
|
||||
}
|
||||
if want["formula"] {
|
||||
input["value_render_option"] = "formula"
|
||||
}
|
||||
}
|
||||
|
||||
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
|
||||
// [row=N] line prefix for easy row-number lookup.
|
||||
var CsvGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+csv-get",
|
||||
Description: "Read a range as CSV (with [row=N] line prefix by default).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+csv-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
}
|
||||
if runtime.Bool("skip-hidden") {
|
||||
input["skip_hidden"] = true
|
||||
}
|
||||
// --max-rows was removed from the CLI surface; --max-chars is the single
|
||||
// read cap. Pin max_rows very high so the tool's own default never binds
|
||||
// before max_chars.
|
||||
input["max_rows"] = unboundedReadLimit
|
||||
if n := runtime.Int("max-chars"); n > 0 {
|
||||
input["max_chars"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// stripRowPrefixFromCsvOutput removes "[row=N]" line prefixes from the tool's
|
||||
// annotated_csv field. Operates client-side because the tool only emits the
|
||||
// annotated form.
|
||||
func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csv, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
lines := strings.Split(csv, "\n")
|
||||
for i, line := range lines {
|
||||
if idx := strings.Index(line, "]"); idx >= 0 && strings.HasPrefix(line, "[row=") {
|
||||
rest := line[idx+1:]
|
||||
lines[i] = strings.TrimPrefix(rest, ",")
|
||||
}
|
||||
}
|
||||
m["annotated_csv"] = strings.Join(lines, "\n")
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
if i := strings.LastIndex(rng, ":"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var digits strings.Builder
|
||||
for _, c := range rng {
|
||||
if c >= '0' && c <= '9' {
|
||||
digits.WriteRune(c)
|
||||
}
|
||||
}
|
||||
if digits.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(digits.String())
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
// is a bare A1 reference. The earlier "must include a sheet prefix"
|
||||
// shape was the odd one out among the get_cell_ranges wrappers and made
|
||||
// callers treat the prefix as either name or id; folding it into the
|
||||
// canonical --sheet-id selector removes that ambiguity.
|
||||
var DropdownGet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-get",
|
||||
Description: "Read the dropdown / data-validation configuration on a range.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dropdown-get"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("range")) == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func dropdownGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"ranges": []string{strings.TrimSpace(runtime.Str("range"))},
|
||||
"include_styles": false,
|
||||
"value_render_option": "formatted_value",
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
return input
|
||||
}
|
||||
291
shortcuts/sheets/lark_sheet_read_data_test.go
Normal file
291
shortcuts/sheets/lark_sheet_read_data_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+cells-get single range + include=style,formula",
|
||||
sc: CellsGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "style,formula"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"A1:B2"},
|
||||
"include_styles": true,
|
||||
"value_render_option": "formula",
|
||||
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
|
||||
},
|
||||
},
|
||||
{
|
||||
// Canonical form: --sheet-id + bare --range. Aligned with
|
||||
// +cells-get / +csv-get; before the e2e BUG-019 fix this
|
||||
// shortcut was the odd one out (range-prefix required).
|
||||
name: "+dropdown-get with --sheet-id",
|
||||
sc: DropdownGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "C2:C6"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"ranges": []interface{}{"C2:C6"},
|
||||
"include_styles": false,
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dropdown-get with --sheet-name",
|
||||
sc: DropdownGet,
|
||||
args: []string{"--url", testURL, "--sheet-name", "Sheet1", "--range", "C2:C6"},
|
||||
toolName: "get_cell_ranges",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_name": "Sheet1",
|
||||
"ranges": []interface{}{"C2:C6"},
|
||||
"include_styles": false,
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownGet_RequiresSheetSelector locks the +cells-get-style
|
||||
// selector contract: at least one of --sheet-id / --sheet-name must be
|
||||
// supplied. Before BUG-019 fix this shortcut required a "Sheet!A1"
|
||||
// prefix inside --range instead; the canonical selector pair is what
|
||||
// every other get_cell_ranges wrapper uses.
|
||||
func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{
|
||||
"--url", testURL, "--range", "A2:A100", "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") {
|
||||
t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadData_RequiresRange covers the trim-based --range guard on the
|
||||
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
|
||||
// must still be rejected by Validate).
|
||||
func TestReadData_RequiresRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
}{
|
||||
{"+cells-get", CellsGet},
|
||||
{"+csv-get", CsvGet},
|
||||
{"+dropdown-get", DropdownGet},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "--range is required") {
|
||||
t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
|
||||
// directly (white-box).
|
||||
func TestInfoTypeFromInclude(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Caller (sheetInfoInput) skips infoTypeFromInclude when len(include)==0,
|
||||
// so the helper only ever sees non-empty input.
|
||||
cases := []struct {
|
||||
include []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"row_heights"}, "row_heights_column_widths"},
|
||||
{[]string{"row_heights", "col_widths"}, "row_heights_column_widths"},
|
||||
{[]string{"hidden_rows", "hidden_cols"}, "hidden_infos"},
|
||||
{[]string{"groups"}, "group_infos"},
|
||||
{[]string{"merges"}, "merged_cells_infos"},
|
||||
{[]string{"row_heights", "merges"}, "all"}, // mixed
|
||||
{[]string{"frozen"}, "all"}, // frozen alone falls back to all
|
||||
{[]string{"unknown"}, "all"}, // unknown → all
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := infoTypeFromInclude(c.include); got != c.want {
|
||||
t.Errorf("infoTypeFromInclude(%v) = %q, want %q", c.include, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCsvGet_StripRowPrefix verifies the client-side post-process for
|
||||
// --include-row-prefix=false.
|
||||
func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] a,b,c\n[row=2] d,e,f",
|
||||
"other": "untouched",
|
||||
}
|
||||
out := stripRowPrefixFromCsvOutput(in).(map[string]interface{})
|
||||
csv := out["annotated_csv"].(string)
|
||||
if csv != " a,b,c\n d,e,f" {
|
||||
t.Errorf("annotated_csv = %q, want stripped prefix", csv)
|
||||
}
|
||||
if out["other"] != "untouched" {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
172
shortcuts/sheets/lark_sheet_search_replace.go
Normal file
172
shortcuts/sheets/lark_sheet_search_replace.go
Normal file
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_search_replace ────────────────────────────────────────
|
||||
//
|
||||
// Wraps search_data (read) and replace_data (write). Both tools take an
|
||||
// `options` sub-object; the CLI flattens its common booleans
|
||||
// (--match-case / --match-entire-cell / --regex / --include-formulas) into
|
||||
// independent flags per the铁律.
|
||||
|
||||
// CellsSearch wraps search_data: find cell coordinates matching --find,
|
||||
// with optional case / regex / whole-cell / formula-text controls.
|
||||
var CellsSearch = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-search",
|
||||
Description: "Find cells matching --find in a spreadsheet (case / regex / whole-cell / formula-text controls).",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-search"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("find")) == "" {
|
||||
return common.FlagErrorf("--find is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func searchInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"search_term": runtime.Str("find"),
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
}
|
||||
if runtime.Changed("offset") && runtime.Int("offset") > 0 {
|
||||
input["offset"] = runtime.Int("offset")
|
||||
}
|
||||
if opts := searchReplaceOptions(runtime); len(opts) > 0 {
|
||||
input["options"] = opts
|
||||
}
|
||||
if n := runtime.Int("max-matches"); n > 0 {
|
||||
input["max_matches"] = n
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// searchReplaceOptions packs the four shared boolean flags into the tool's
|
||||
// `options` sub-object. Empty result → caller should omit the field.
|
||||
func searchReplaceOptions(runtime flagView) map[string]interface{} {
|
||||
opts := map[string]interface{}{}
|
||||
if runtime.Bool("match-case") {
|
||||
opts["match_case"] = true
|
||||
}
|
||||
if runtime.Bool("match-entire-cell") {
|
||||
opts["match_entire_cell"] = true
|
||||
}
|
||||
if runtime.Bool("regex") {
|
||||
opts["use_regex"] = true
|
||||
}
|
||||
if runtime.Bool("include-formulas") {
|
||||
opts["match_formulas"] = true
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// CellsReplace wraps replace_data: find and replace text across a
|
||||
// spreadsheet, with the same option controls as +cells-search.
|
||||
var CellsReplace = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-replace",
|
||||
Description: "Find and replace text in a spreadsheet (case / regex / whole-cell / formula-text controls).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-replace"),
|
||||
Validate: validateViaInput(replaceInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := replaceInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "replace_data", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := replaceInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "replace_data", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Always preview with --dry-run before running — replace can mutate every matching cell across the sheet.",
|
||||
},
|
||||
}
|
||||
|
||||
func replaceInput(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("find")) == "" {
|
||||
return nil, common.FlagErrorf("--find is required")
|
||||
}
|
||||
if !runtime.Changed("replacement") {
|
||||
return nil, common.FlagErrorf("--replacement is required (pass an empty string to delete matches)")
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"search_term": runtime.Str("find"),
|
||||
"replace_term": runtime.Str("replacement"),
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
}
|
||||
if opts := searchReplaceOptions(runtime); len(opts) > 0 {
|
||||
input["options"] = opts
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
102
shortcuts/sheets/lark_sheet_search_replace_test.go
Normal file
102
shortcuts/sheets/lark_sheet_search_replace_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
wantOptions map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+cells-search regex + match-case",
|
||||
sc: CellsSearch,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--regex", "--match-case"},
|
||||
toolName: "search_data",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"search_term": "foo",
|
||||
},
|
||||
wantOptions: map[string]interface{}{
|
||||
"match_case": true,
|
||||
"use_regex": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-search all four options",
|
||||
sc: CellsSearch,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "x", "--match-case", "--match-entire-cell", "--regex", "--include-formulas"},
|
||||
toolName: "search_data",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"search_term": "x",
|
||||
},
|
||||
wantOptions: map[string]interface{}{
|
||||
"match_case": true,
|
||||
"match_entire_cell": true,
|
||||
"use_regex": true,
|
||||
"match_formulas": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-replace empty replace deletes match",
|
||||
sc: CellsReplace,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--replacement", ""},
|
||||
toolName: "replace_data",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"search_term": "foo",
|
||||
"replace_term": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
if tt.wantOptions != nil {
|
||||
opts, _ := got["options"].(map[string]interface{})
|
||||
if opts == nil {
|
||||
t.Fatalf("options missing: %#v", got)
|
||||
}
|
||||
for k, want := range tt.wantOptions {
|
||||
if opts[k] != want {
|
||||
t.Errorf("options[%q] = %v, want %v", k, opts[k], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsReplace_RequireFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
// --replace not passed at all (vs empty string) should error.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "replace") {
|
||||
t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
679
shortcuts/sheets/lark_sheet_sheet_structure.go
Normal file
679
shortcuts/sheets/lark_sheet_sheet_structure.go
Normal file
@@ -0,0 +1,679 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_sheet_structure ───────────────────────────────────────
|
||||
//
|
||||
// Wraps get_sheet_structure (read) and modify_sheet_structure (write,
|
||||
// operation-enum dispatch). All region/position arguments use A1-style
|
||||
// strings (1-based row numbers like "3:7" / "5", or column letters like
|
||||
// "C:F" / "C"); dim-* / resize never expose 0-based int indices on the CLI
|
||||
// surface, so there is no inclusive/exclusive ambiguity across commands.
|
||||
// parseA1Range / parseA1Position handle parsing into the 0-based ints that
|
||||
// dim-move's native v3 endpoint expects.
|
||||
//
|
||||
// +rows-resize / +cols-resize live in lark_sheet_range_operations (different
|
||||
// tool); they are only grouped under "工作表" for discoverability.
|
||||
|
||||
// SheetInfo wraps get_sheet_structure: row heights, column widths, hidden
|
||||
// rows/cols, merged cells, row/column groups, and freeze counts for one
|
||||
// sub-sheet (optionally limited to a range).
|
||||
var SheetInfo = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+sheet-info",
|
||||
Description: "Get a sub-sheet's layout metadata: row heights, column widths, hidden rows/cols, merges, groups, freeze.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+sheet-info"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := resolveSheetSelector(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Frozen rows / columns are top-level fields and are returned regardless of --include.",
|
||||
},
|
||||
}
|
||||
|
||||
func sheetInfoInput(runtime *common.RuntimeContext, token, sheetID, sheetName string) map[string]interface{} {
|
||||
input := map[string]interface{}{"excel_id": token}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if r := strings.TrimSpace(runtime.Str("range")); r != "" {
|
||||
input["range"] = r
|
||||
}
|
||||
if include := runtime.StrSlice("include"); len(include) > 0 {
|
||||
if t := infoTypeFromInclude(include); t != "" {
|
||||
input["info_type"] = t
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// infoTypeFromInclude maps the fine-grained --include vocabulary to the
|
||||
// tool's coarse info_type enum. When --include spans multiple categories
|
||||
// (or asks for "frozen", which is always returned), we fall back to "all".
|
||||
func infoTypeFromInclude(include []string) string {
|
||||
groups := map[string]string{
|
||||
"row_heights": "row_heights_column_widths",
|
||||
"col_widths": "row_heights_column_widths",
|
||||
"hidden_rows": "hidden_infos",
|
||||
"hidden_cols": "hidden_infos",
|
||||
"groups": "group_infos",
|
||||
"merges": "merged_cells_infos",
|
||||
"frozen": "", // any info_type returns frozen; falling back to all is fine
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range include {
|
||||
g, ok := groups[v]
|
||||
if !ok || g == "" {
|
||||
return "all"
|
||||
}
|
||||
seen[g] = struct{}{}
|
||||
}
|
||||
if len(seen) != 1 {
|
||||
return "all"
|
||||
}
|
||||
for g := range seen {
|
||||
return g
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
// ─── +dim-* (modify_sheet_structure) ──────────────────────────────────
|
||||
|
||||
// DimInsert inserts blank rows / columns and optionally inherits style from
|
||||
// the adjacent dimension.
|
||||
var DimInsert = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dim-insert",
|
||||
Description: "Insert blank rows or columns at a given position.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-insert"),
|
||||
Validate: validateViaInput(dimInsertInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dimInsertInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// dimInsertInput passes --position (1-based row number "3" or column letter
|
||||
// "C") straight to the tool's `position` field; --count maps to `count`.
|
||||
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("position") {
|
||||
return nil, common.FlagErrorf("--position is required")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, common.FlagErrorf("--count is required")
|
||||
}
|
||||
position := strings.TrimSpace(runtime.Str("position"))
|
||||
if _, _, err := parseA1Position(position); err != nil {
|
||||
return nil, common.FlagErrorf("invalid --position %q: %v", position, err)
|
||||
}
|
||||
count := runtime.Int("count")
|
||||
if count <= 0 {
|
||||
return nil, common.FlagErrorf("--count must be > 0 (got %d)", count)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": "insert",
|
||||
"position": position,
|
||||
"count": count,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
switch runtime.Str("inherit-style") {
|
||||
case "before":
|
||||
input["side"] = "before"
|
||||
case "after":
|
||||
input["side"] = "after"
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// DimDelete deletes rows / columns — irreversible, high-risk-write.
|
||||
var DimDelete = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dim-delete",
|
||||
Description: "Delete rows or columns (irreversible).",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-delete"),
|
||||
Validate: validateDimRangeOp("delete"),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Row/column deletion is irreversible. Always preview with --dry-run first.",
|
||||
},
|
||||
}
|
||||
|
||||
// validateDimRangeOp returns a Validate closure that delegates to
|
||||
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
|
||||
// extra `op` argument. Token check happens here; the rest is the builder.
|
||||
func validateDimRangeOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return 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 = dimRangeOpInput(runtime, token, sheetID, sheetName, op)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// validateDimGroupOp is the group/ungroup counterpart of validateDimRangeOp.
|
||||
func validateDimGroupOp(op string) func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return 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 = dimGroupInput(runtime, token, sheetID, sheetName, op)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// DimHide / DimUnhide toggle visibility on a row/column range.
|
||||
var DimHide = newDimRangeOpShortcut(
|
||||
"+dim-hide", "Hide rows or columns within a range.", "hide", "write",
|
||||
)
|
||||
var DimUnhide = newDimRangeOpShortcut(
|
||||
"+dim-unhide", "Unhide rows or columns within a range.", "unhide", "write",
|
||||
)
|
||||
|
||||
// DimGroup / DimUngroup manage row/column outline groups.
|
||||
var DimGroup = newDimGroupShortcut(
|
||||
"+dim-group", "Group rows or columns into an outline (collapsible).", "group",
|
||||
)
|
||||
var DimUngroup = newDimGroupShortcut(
|
||||
"+dim-ungroup", "Remove a row/column outline group.", "ungroup",
|
||||
)
|
||||
|
||||
// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that
|
||||
// dimension.
|
||||
var DimFreeze = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dim-freeze",
|
||||
Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-freeze"),
|
||||
Validate: validateViaInput(dimFreezeInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimFreezeInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dimFreezeInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("dimension") {
|
||||
return nil, common.FlagErrorf("--dimension is required")
|
||||
}
|
||||
if !runtime.Changed("count") {
|
||||
return nil, common.FlagErrorf("--count is required (0 unfreezes)")
|
||||
}
|
||||
if runtime.Int("count") < 0 {
|
||||
return nil, common.FlagErrorf("--count must be >= 0")
|
||||
}
|
||||
dim := runtime.Str("dimension")
|
||||
count := runtime.Int("count")
|
||||
op := "freeze"
|
||||
if count == 0 {
|
||||
op = "unfreeze"
|
||||
}
|
||||
input := map[string]interface{}{"excel_id": token, "operation": op}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if op == "freeze" {
|
||||
if dim == "row" {
|
||||
input["freeze_rows"] = count
|
||||
} else {
|
||||
input["freeze_columns"] = count
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// dimRangeOpInput builds the tool input for delete/hide/unhide/group/ungroup
|
||||
// which all take a `range` string field. --range is a 1-based A1 closed range
|
||||
// ("3:7" / "5" for rows, "C:F" / "C" for columns) and passes straight through
|
||||
// after format validation.
|
||||
func dimRangeOpInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !runtime.Changed("range") {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if _, _, _, err := parseA1Range(rangeStr); err != nil {
|
||||
return nil, common.FlagErrorf("invalid --range %q: %v", rangeStr, err)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"operation": op,
|
||||
"range": rangeStr,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// newDimRangeOpShortcut builds the shared shape for hide / unhide.
|
||||
func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut {
|
||||
return common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: command,
|
||||
Description: desc,
|
||||
Risk: risk,
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor(command),
|
||||
Validate: validateDimRangeOp(op),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newDimGroupShortcut builds the shared shape for group / ungroup. It adds
|
||||
// --depth (currently unused server-side — accepted for forward-compat per
|
||||
// the canonical spec) and --group-state (group only, defaults to expand).
|
||||
func newDimGroupShortcut(command, desc, op string) common.Shortcut {
|
||||
flags := flagsFor(command)
|
||||
return common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: command,
|
||||
Description: desc,
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flags,
|
||||
Validate: validateDimGroupOp(op),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dimGroupInput(runtime, token, sheetID, sheetName, op)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dimGroupInput(runtime, token, sheetID, sheetName, op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dimGroupInput(runtime flagView, token, sheetID, sheetName, op string) (map[string]interface{}, error) {
|
||||
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, op)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if op == "group" {
|
||||
if gs := runtime.Str("group-state"); gs != "" {
|
||||
input["group_state"] = gs
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ─── A1 parsing helpers ───────────────────────────────────────────────
|
||||
|
||||
// parseA1Range parses an A1 closed range ("3:7" / "5" / "C:F" / "C") into
|
||||
// the inferred dimension ("row" or "column") and 0-based inclusive indices.
|
||||
// Single-element form yields startIdx == endIdx. Mixing digits and letters
|
||||
// across the two sides ("3:C") is rejected.
|
||||
func parseA1Range(s string) (dimension string, startIdx, endIdx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, 0, fmt.Errorf("range is empty")
|
||||
}
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) > 2 {
|
||||
return "", 0, 0, fmt.Errorf("expected \"start:end\" or single element")
|
||||
}
|
||||
dim1, idx1, err := parseA1Position(parts[0])
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return dim1, idx1, idx1, nil
|
||||
}
|
||||
dim2, idx2, err := parseA1Position(parts[1])
|
||||
if err != nil {
|
||||
return "", 0, 0, err
|
||||
}
|
||||
if dim1 != dim2 {
|
||||
return "", 0, 0, fmt.Errorf("cannot mix row (digits) and column (letters) in one range")
|
||||
}
|
||||
if idx2 < idx1 {
|
||||
return "", 0, 0, fmt.Errorf("end position is before start")
|
||||
}
|
||||
return dim1, idx1, idx2, nil
|
||||
}
|
||||
|
||||
// parseA1Position parses a single A1 position element: pure digits → row
|
||||
// (1-based number, returned as 0-based idx); pure letters → column (letters
|
||||
// case-insensitive, "A" → 0, "AA" → 26).
|
||||
func parseA1Position(s string) (dimension string, idx int, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", 0, fmt.Errorf("position is empty")
|
||||
}
|
||||
isDigits := true
|
||||
isLetters := true
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
isDigits = false
|
||||
}
|
||||
if !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||
isLetters = false
|
||||
}
|
||||
}
|
||||
if isDigits {
|
||||
n, _ := strconv.Atoi(s)
|
||||
if n <= 0 {
|
||||
return "", 0, fmt.Errorf("row number must be >= 1 (got %q)", s)
|
||||
}
|
||||
return "row", n - 1, nil
|
||||
}
|
||||
if isLetters {
|
||||
return "column", letterToColumnIndex(s), nil
|
||||
}
|
||||
return "", 0, fmt.Errorf("expected pure digits (row number) or letters (column letter), got %q", s)
|
||||
}
|
||||
|
||||
// columnIndexToLetter converts a 0-based column index to the spreadsheet
|
||||
// letter notation (0 → "A", 25 → "Z", 26 → "AA", 701 → "ZZ", 702 → "AAA").
|
||||
// Used by +workbook helpers that need to format absolute column references.
|
||||
func columnIndexToLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
idx++
|
||||
var out []byte
|
||||
for idx > 0 {
|
||||
idx--
|
||||
out = append([]byte{byte('A' + idx%26)}, out...)
|
||||
idx /= 26
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
|
||||
//
|
||||
// Moves a contiguous block of rows or columns to a new index in the same
|
||||
// sheet via the native v3 move_dimension endpoint (not the One-OpenAPI
|
||||
// dispatcher). CLI accepts --source-range (A1 closed range like "3:7" or
|
||||
// "C:F") + --target (A1 single position like "12" or "H"); both are parsed
|
||||
// into the 0-based int indices that v3 move_dimension expects.
|
||||
|
||||
var DimMove = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dim-move",
|
||||
Description: "Move a contiguous block of rows or columns to a new position (re-numbers neighbors).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dim-move"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buildDimMovePlan(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
return common.NewDryRunAPI().
|
||||
POST(dimMovePath(token, sheetSelectorPlaceholder(sheetID, sheetName))).
|
||||
Body(dimMoveBody(runtime)).
|
||||
Set("spreadsheet_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// v3 move_dimension carries sheet_id in the path. Resolve
|
||||
// sheet_name client-side when needed (reuses lookupSheetIndex
|
||||
// which fetches workbook structure).
|
||||
if sheetID == "" {
|
||||
lookedID, _, err := lookupSheetIndex(ctx, runtime, token, "", sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID = lookedID
|
||||
}
|
||||
data, err := runtime.CallAPI("POST", dimMovePath(token, sheetID), nil, dimMoveBody(runtime))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// dimMovePlan is the parsed form of --source-range / --target.
|
||||
type dimMovePlan struct {
|
||||
dimension string // "row" / "column"
|
||||
startIdx int // 0-based inclusive
|
||||
endIdx int // 0-based inclusive
|
||||
targetIdx int // 0-based; destination position (move inserts before this)
|
||||
}
|
||||
|
||||
// buildDimMovePlan parses --source-range + --target and enforces that the
|
||||
// target dimension matches the source. Used by both Validate and Execute.
|
||||
func buildDimMovePlan(runtime flagView) (*dimMovePlan, error) {
|
||||
if !runtime.Changed("source-range") || !runtime.Changed("target") {
|
||||
return nil, common.FlagErrorf("--source-range and --target are required")
|
||||
}
|
||||
src := strings.TrimSpace(runtime.Str("source-range"))
|
||||
dim, startIdx, endIdx, err := parseA1Range(src)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("invalid --source-range %q: %v", src, err)
|
||||
}
|
||||
tgt := strings.TrimSpace(runtime.Str("target"))
|
||||
tgtDim, tgtIdx, err := parseA1Position(tgt)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("invalid --target %q: %v", tgt, err)
|
||||
}
|
||||
if tgtDim != dim {
|
||||
return nil, common.FlagErrorf("--target %q dimension (%s) must match --source-range %q dimension (%s)", tgt, tgtDim, src, dim)
|
||||
}
|
||||
return &dimMovePlan{dimension: dim, startIdx: startIdx, endIdx: endIdx, targetIdx: tgtIdx}, nil
|
||||
}
|
||||
|
||||
// dimMovePath builds the native v3 move_dimension endpoint. sheet_id lives in
|
||||
// the path (unlike the v2 dimension_range body that the earlier build used).
|
||||
func dimMovePath(token, sheetID string) string {
|
||||
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
|
||||
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
|
||||
}
|
||||
|
||||
func dimMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
plan, err := buildDimMovePlan(runtime)
|
||||
if err != nil {
|
||||
// Validate has already rejected this case; emit an empty body
|
||||
// rather than panic on the dry-run path.
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
dim := "ROWS"
|
||||
if plan.dimension == "column" {
|
||||
dim = "COLUMNS"
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"source": map[string]interface{}{
|
||||
"major_dimension": dim,
|
||||
"start_index": plan.startIdx,
|
||||
"end_index": plan.endIdx,
|
||||
},
|
||||
"destination_index": plan.targetIdx,
|
||||
}
|
||||
}
|
||||
342
shortcuts/sheets/lark_sheet_sheet_structure_test.go
Normal file
342
shortcuts/sheets/lark_sheet_sheet_structure_test.go
Normal file
@@ -0,0 +1,342 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestSheetStructureShortcuts_DryRun covers all 8 shortcuts in
|
||||
// lark_sheet_sheet_structure (sheet-info + 7 dim-*) and verifies that the
|
||||
// CLI's A1-style --range / --position / --count flags map straight through
|
||||
// to the tool's `range` / `position` / `count` fields (or are normalised
|
||||
// per shortcut's wire shape).
|
||||
func TestSheetStructureShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+sheet-info with include single category → narrow info_type",
|
||||
sc: SheetInfo,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,col_widths"},
|
||||
toolName: "get_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"info_type": "row_heights_column_widths",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-info with mixed include → all",
|
||||
sc: SheetInfo,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--include", "row_heights,merges"},
|
||||
toolName: "get_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"info_type": "all",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-insert row position=6 count=3 inherit-before",
|
||||
sc: DimInsert,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": "6",
|
||||
"count": float64(3),
|
||||
"side": "before",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-insert column position=C count=2",
|
||||
sc: DimInsert,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "C", "--count", "2"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "insert",
|
||||
"sheet_id": testSheetID,
|
||||
"position": "C",
|
||||
"count": float64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-delete column B:D",
|
||||
sc: DimDelete,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "B:D"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "delete",
|
||||
"sheet_id": testSheetID,
|
||||
"range": "B:D",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-hide row 3:5",
|
||||
sc: DimHide,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:5"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide",
|
||||
"sheet_id": testSheetID,
|
||||
"range": "3:5",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-unhide column AA:AC",
|
||||
sc: DimUnhide,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "AA:AC"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "unhide",
|
||||
"sheet_id": testSheetID,
|
||||
"range": "AA:AC",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-freeze row count=2 → freeze_rows",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "freeze",
|
||||
"sheet_id": testSheetID,
|
||||
"freeze_rows": float64(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-freeze count=0 → unfreeze",
|
||||
sc: DimFreeze,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "column", "--count", "0"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "unfreeze",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-group row 1:5 fold",
|
||||
sc: DimGroup,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5", "--group-state", "fold"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "group",
|
||||
"sheet_id": testSheetID,
|
||||
"range": "1:5",
|
||||
"group_state": "fold",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dim-ungroup row 1:5",
|
||||
sc: DimUngroup,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "1:5"},
|
||||
toolName: "modify_sheet_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "ungroup",
|
||||
"sheet_id": testSheetID,
|
||||
"range": "1:5",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimRange_Validation covers the A1 range parser's edge cases routed
|
||||
// through +dim-hide (any --range shortcut works; we just need to exercise
|
||||
// the validator).
|
||||
func TestDimRange_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "end before start",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "5:3", "--dry-run"},
|
||||
want: "end position is before start",
|
||||
},
|
||||
{
|
||||
name: "mix row+column",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "3:C", "--dry-run"},
|
||||
want: "cannot mix row",
|
||||
},
|
||||
{
|
||||
name: "invalid characters",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--dry-run"},
|
||||
want: "expected pure digits",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimMove_DryRun verifies the native v3 move_dimension payload shape.
|
||||
// CLI's --source-range "1:3" (1-based inclusive) is parsed into
|
||||
// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is
|
||||
// carried in the path, not the body. --target "11" → destination_index=10.
|
||||
func TestDimMove_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, DimMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "1:3", "--target", "11",
|
||||
})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
wantURL := "/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension"
|
||||
if !strings.Contains(c["url"].(string), wantURL) {
|
||||
t.Errorf("url = %v, want suffix %v", c["url"], wantURL)
|
||||
}
|
||||
body, _ := c["body"].(map[string]interface{})
|
||||
src, _ := body["source"].(map[string]interface{})
|
||||
if src["major_dimension"] != "ROWS" {
|
||||
t.Errorf("source.major_dimension = %v, want ROWS", src["major_dimension"])
|
||||
}
|
||||
if src["start_index"].(float64) != 0 {
|
||||
t.Errorf("start_index = %v, want 0", src["start_index"])
|
||||
}
|
||||
if src["end_index"].(float64) != 2 {
|
||||
t.Errorf("end_index = %v, want 2 (0-based inclusive)", src["end_index"])
|
||||
}
|
||||
if body["destination_index"].(float64) != 10 {
|
||||
t.Errorf("destination_index = %v, want 10 (target \"11\" → 0-based 10)", body["destination_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimMove_Column exercises the column path: --source-range "C:F" →
|
||||
// COLUMNS / start=2 / end=5; --target "H" → destination_index=7.
|
||||
func TestDimMove_Column(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, DimMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "C:F", "--target", "H",
|
||||
})
|
||||
c := calls[0].(map[string]interface{})
|
||||
body, _ := c["body"].(map[string]interface{})
|
||||
src, _ := body["source"].(map[string]interface{})
|
||||
if src["major_dimension"] != "COLUMNS" {
|
||||
t.Errorf("major_dimension = %v, want COLUMNS", src["major_dimension"])
|
||||
}
|
||||
if src["start_index"].(float64) != 2 || src["end_index"].(float64) != 5 {
|
||||
t.Errorf("source = %v, want start=2 end=5", src)
|
||||
}
|
||||
if body["destination_index"].(float64) != 7 {
|
||||
t.Errorf("destination_index = %v, want 7", body["destination_index"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDimMove_MismatchedDimension verifies that mixing source row + target
|
||||
// column (or vice versa) is rejected at Validate.
|
||||
func TestDimMove_MismatchedDimension(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "1:3", "--target", "H", "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") {
|
||||
t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseA1Range covers parser edge cases directly.
|
||||
func TestParseA1Range(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
dim string
|
||||
start int
|
||||
end int
|
||||
wantErr bool
|
||||
}{
|
||||
{"3:7", "row", 2, 6, false},
|
||||
{"5", "row", 4, 4, false},
|
||||
{"C:F", "column", 2, 5, false},
|
||||
{"C", "column", 2, 2, false},
|
||||
{"aa:ac", "column", 26, 28, false}, // lower-case letters accepted
|
||||
{"", "", 0, 0, true},
|
||||
{"3:C", "", 0, 0, true},
|
||||
{"7:3", "", 0, 0, true},
|
||||
{"A1", "", 0, 0, true}, // cell ref, not a row/col range
|
||||
{"3:5:7", "", 0, 0, true},
|
||||
{"0", "", 0, 0, true}, // rows are 1-based
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dim, start, end, err := parseA1Range(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("parseA1Range(%q) = (%q, %d, %d, nil), want error", c.in, dim, start, end)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseA1Range(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if dim != c.dim || start != c.start || end != c.end {
|
||||
t.Errorf("parseA1Range(%q) = (%q, %d, %d), want (%q, %d, %d)", c.in, dim, start, end, c.dim, c.start, c.end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestColumnIndexToLetter exercises the corner cases of the letter helper
|
||||
// (still in use by lark_sheet_workbook.go for absolute column refs).
|
||||
func TestColumnIndexToLetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
idx int
|
||||
want string
|
||||
}{
|
||||
{0, "A"}, {25, "Z"}, {26, "AA"}, {27, "AB"}, {51, "AZ"},
|
||||
{52, "BA"}, {701, "ZZ"}, {702, "AAA"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := columnIndexToLetter(c.idx); got != c.want {
|
||||
t.Errorf("columnIndexToLetter(%d) = %q, want %q", c.idx, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
1035
shortcuts/sheets/lark_sheet_workbook.go
Normal file
1035
shortcuts/sheets/lark_sheet_workbook.go
Normal file
File diff suppressed because it is too large
Load Diff
439
shortcuts/sheets/lark_sheet_workbook_test.go
Normal file
439
shortcuts/sheets/lark_sheet_workbook_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestWorkbookShortcuts_DryRun covers all 9 lark_sheet_workbook shortcuts
|
||||
// (WorkbookInfo + 8 sheet-* variants) by asserting the One-OpenAPI body
|
||||
// the dry-run renders. Together they exercise every dispatch arm of
|
||||
// modify_workbook_structure plus the read tool.
|
||||
func TestWorkbookShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+workbook-info read",
|
||||
sc: WorkbookInfo,
|
||||
args: []string{"--url", testURL},
|
||||
toolName: "get_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-create with all options",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--url", testURL, "--title", "Q1", "--index", "1", "--row-count", "300", "--col-count", "10"},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "create",
|
||||
"sheet_name": "Q1",
|
||||
"target_index": float64(1),
|
||||
"rows": float64(300),
|
||||
"columns": float64(10),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete by id",
|
||||
sc: SheetDelete,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "delete",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-rename by name",
|
||||
sc: SheetRename,
|
||||
args: []string{"--url", testURL, "--sheet-name", "汇总", "--title", "Q1 汇总"},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "rename",
|
||||
"sheet_name": "汇总",
|
||||
"new_name": "Q1 汇总",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-copy without explicit title",
|
||||
sc: SheetCopy,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "duplicate",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-copy with new title and index",
|
||||
sc: SheetCopy,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--title", "副本", "--index", "0"},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "duplicate",
|
||||
"sheet_id": testSheetID,
|
||||
"new_name": "副本",
|
||||
"target_index": float64(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-hide",
|
||||
sc: SheetHide,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-unhide",
|
||||
sc: SheetUnhide,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "unhide",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-set-tab-color hex",
|
||||
sc: SheetSetTabColor,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", "#FF0000"},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "set_tab_color",
|
||||
"sheet_id": testSheetID,
|
||||
"tab_color": "#FF0000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-set-tab-color empty clears",
|
||||
sc: SheetSetTabColor,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--color", ""},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "set_tab_color",
|
||||
"sheet_id": testSheetID,
|
||||
"tab_color": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetMove_DryRunResolvePlaceholders verifies the move shortcut emits
|
||||
// <resolve> placeholders for fields it would otherwise have to look up
|
||||
// at execute time. DryRun must stay network-free.
|
||||
func TestSheetMove_DryRunResolvePlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantSheetID string
|
||||
wantSourceIdx interface{}
|
||||
}{
|
||||
{
|
||||
name: "id only, no source-index → both literal + placeholder",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0"},
|
||||
wantSheetID: testSheetID,
|
||||
wantSourceIdx: "<resolve>",
|
||||
},
|
||||
{
|
||||
name: "name only → sheet_id placeholder + source_index placeholder",
|
||||
args: []string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
|
||||
wantSheetID: "<resolve:汇总>",
|
||||
wantSourceIdx: "<resolve>",
|
||||
},
|
||||
{
|
||||
name: "id + source-index → both literal",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--index", "0", "--source-index", "5"},
|
||||
wantSheetID: testSheetID,
|
||||
wantSourceIdx: float64(5),
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, SheetMove, tt.args)
|
||||
input := decodeToolInput(t, body, "modify_workbook_structure")
|
||||
if got := input["sheet_id"]; got != tt.wantSheetID {
|
||||
t.Errorf("sheet_id = %#v, want %#v", got, tt.wantSheetID)
|
||||
}
|
||||
if got := input["source_index"]; got != tt.wantSourceIdx {
|
||||
t.Errorf("source_index = %#v, want %#v", got, tt.wantSourceIdx)
|
||||
}
|
||||
if got := input["target_index"]; got != float64(0) {
|
||||
t.Errorf("target_index = %#v, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetDelete_HighRiskWriteRequiresYes verifies the framework gate on
|
||||
// high-risk-write — exit code 10 (confirmation_required) without --yes.
|
||||
func TestSheetDelete_HighRiskWriteRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, SheetDelete, []string{"--url", testURL, "--sheet-id", testSheetID})
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required error; got nil. stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
|
||||
t.Errorf("expected confirmation envelope; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbook_Validation covers a few critical validation paths shared
|
||||
// across the package's helpers (XOR token, XOR sheet selector, required
|
||||
// flags).
|
||||
func TestWorkbook_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "+workbook-info needs --url or --spreadsheet-token",
|
||||
sc: WorkbookInfo,
|
||||
args: []string{},
|
||||
wantMsg: "at least one of --url or --spreadsheet-token",
|
||||
},
|
||||
{
|
||||
name: "+workbook-info rejects both url and token",
|
||||
sc: WorkbookInfo,
|
||||
args: []string{"--url", testURL, "--spreadsheet-token", testToken},
|
||||
wantMsg: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "+sheet-delete needs sheet selector",
|
||||
sc: SheetDelete,
|
||||
args: []string{"--url", testURL},
|
||||
wantMsg: "at least one of --sheet-id or --sheet-name",
|
||||
},
|
||||
{
|
||||
name: "+sheet-create requires --title",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--url", testURL},
|
||||
wantMsg: "required flag(s) \"title\" not set",
|
||||
},
|
||||
{
|
||||
name: "+sheet-create row-count over cap",
|
||||
sc: SheetCreate,
|
||||
args: []string{"--url", testURL, "--title", "X", "--row-count", "999999"},
|
||||
wantMsg: "--row-count must be between",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, tt.wantMsg) {
|
||||
t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── +workbook-create / +workbook-export (legacy OAPI) ───────────────
|
||||
|
||||
// TestWorkbookCreate_DryRun verifies the two-step plan (create
|
||||
// spreadsheet + optional set_cell_range follow-up) is rendered.
|
||||
func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("minimal title only", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
|
||||
t.Errorf("url = %v, want /open-apis/sheets/v3/spreadsheets", c["url"])
|
||||
}
|
||||
body, _ := c["body"].(map[string]interface{})
|
||||
if body["title"] != "MySheet" {
|
||||
t.Errorf("body.title = %v, want MySheet", body["title"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95],["bob",88]]`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
fill := calls[1].(map[string]interface{})
|
||||
if !strings.Contains(fill["url"].(string), "/sheet_ai/v2/spreadsheets/") {
|
||||
t.Errorf("fill url = %v, want sheet_ai/v2 path", fill["url"])
|
||||
}
|
||||
body, _ := fill["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B3" {
|
||||
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_DataValidation rejects bad JSON shape.
|
||||
func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, append(tt.args, "--dry-run"))
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
t.Errorf("create body = %#v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
|
||||
"--output-path", "/tmp/out.csv",
|
||||
})
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export missing sub_id: %#v", body)
|
||||
}
|
||||
dl := calls[2].(map[string]interface{})
|
||||
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
|
||||
t.Errorf("download url = %v", dl["url"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv requires --sheet-id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "--sheet-id is required") {
|
||||
t.Errorf("expected sheet-id guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// assertInputEquals compares the decoded tool input map against the wanted
|
||||
// fields. Extra fields in `got` are allowed (defaults, optional fields);
|
||||
// every key in `want` must match exactly.
|
||||
func assertInputEquals(t *testing.T, got, want map[string]interface{}) {
|
||||
t.Helper()
|
||||
for k, wv := range want {
|
||||
gv, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("missing input key %q (got=%#v)", k, got)
|
||||
continue
|
||||
}
|
||||
if !deepEqualJSON(gv, wv) {
|
||||
t.Errorf("input[%q] = %#v, want %#v", k, gv, wv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deepEqualJSON compares JSON-shaped values (post-Unmarshal) — handles
|
||||
// the fact that numbers come back as float64 and maps as map[string]interface{}.
|
||||
func deepEqualJSON(a, b interface{}) bool {
|
||||
switch av := a.(type) {
|
||||
case map[string]interface{}:
|
||||
bv, ok := b.(map[string]interface{})
|
||||
if !ok || len(av) != len(bv) {
|
||||
return false
|
||||
}
|
||||
for k, v := range av {
|
||||
if !deepEqualJSON(v, bv[k]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []interface{}:
|
||||
bv, ok := b.([]interface{})
|
||||
if !ok || len(av) != len(bv) {
|
||||
return false
|
||||
}
|
||||
for i := range av {
|
||||
if !deepEqualJSON(av[i], bv[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
824
shortcuts/sheets/lark_sheet_write_cells.go
Normal file
824
shortcuts/sheets/lark_sheet_write_cells.go
Normal file
@@ -0,0 +1,824 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ─── lark_sheet_write_cells ───────────────────────────────────────────
|
||||
//
|
||||
// Wraps:
|
||||
// - set_cell_range (powers +cells-set / +cells-set-style /
|
||||
// +dropdown-set / +dropdown-update / +dropdown-delete)
|
||||
// - set_range_from_csv (powers +csv-put)
|
||||
//
|
||||
// +cells-set-image is a `cli_only_derivative` shortcut (needs a local file
|
||||
// upload before calling set_cell_range); it lives in the cli-only batch
|
||||
// where the upload helper is shared with +workbook-create / +dim-move /
|
||||
// +workbook-export.
|
||||
//
|
||||
// All set_cell_range-backed shortcuts construct a cells matrix whose
|
||||
// dimensions exactly match the target range — the tool errors on mismatch.
|
||||
|
||||
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
|
||||
// (JSON), with an optional --copy-to-range to replicate the written block
|
||||
// across a larger area (formula refs auto-shift).
|
||||
var CellsSet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set",
|
||||
Description: "Write values / formulas / styles / comments / data validation / embed-image to a cell range.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set"),
|
||||
Validate: validateViaInput(cellsSetInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := cellsSetInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func cellsSetInput(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, common.FlagErrorf("--range is required")
|
||||
}
|
||||
cells, err := requireJSONArray(runtime, "cells")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"cells": cells,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if !runtime.Bool("allow-overwrite") {
|
||||
input["allow_overwrite"] = false
|
||||
}
|
||||
if copyTo := strings.TrimSpace(runtime.Str("copy-to-range")); copyTo != "" {
|
||||
input["copy_to_range"] = copyTo
|
||||
}
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// CellsSetStyle stamps a single style block across every cell in --range.
|
||||
// Style is composed from a dozen flat flags (background-color, font-color,
|
||||
// font-size, font-style, font-weight, font-line, horizontal-alignment,
|
||||
// vertical-alignment, word-wrap, number-format) plus --border-styles for
|
||||
// the only field that still needs a nested object. At least one flag must
|
||||
// be set.
|
||||
var CellsSetStyle = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set-style",
|
||||
Description: "Apply style flags to every cell in a range (values / formulas untouched).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set-style"),
|
||||
Validate: validateViaInput(cellsSetStyleInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := cellsSetStyleInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := cellsSetStyleInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if rangeStr == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
|
||||
}
|
||||
if err := requireAnyStyleFlag(runtime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cellStyle := buildCellStyleFromFlags(runtime)
|
||||
borderStyles, err := borderStylesFromFlag(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := make([][]interface{}, rows)
|
||||
for r := range cells {
|
||||
row := make([]interface{}, cols)
|
||||
for c := range row {
|
||||
cell := map[string]interface{}{}
|
||||
if len(cellStyle) > 0 {
|
||||
cell["cell_styles"] = cellStyle
|
||||
}
|
||||
if borderStyles != nil {
|
||||
cell["border_styles"] = borderStyles
|
||||
}
|
||||
row[c] = cell
|
||||
}
|
||||
cells[r] = row
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": rangeStr,
|
||||
"cells": cells,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
|
||||
// plain values. Use +cells-set for anything richer (formula / style / note).
|
||||
var CsvPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+csv-put",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+csv-put"), // includes the hidden --range alias (defined in the base flags table)
|
||||
PostMount: func(cmd *cobra.Command) {
|
||||
// --range is an accepted alias for --start-cell (see csvPutInput).
|
||||
// Neither is individually required; exactly one must be set. flag-defs
|
||||
// marks --start-cell required, so clear that annotation and switch to a
|
||||
// one-required group — otherwise cobra rejects `--range A1` for a
|
||||
// missing --start-cell before the handler ever runs.
|
||||
if fl := cmd.Flags().Lookup("start-cell"); fl != nil {
|
||||
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
cmd.MarkFlagsOneRequired("start-cell", "range")
|
||||
},
|
||||
Validate: validateViaInput(csvPutInput),
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := csvPutInput(runtime, token, sheetID, sheetName)
|
||||
dr := invokeToolDryRun(token, ToolKindWrite, "set_range_from_csv", input)
|
||||
if rng, ok := csvPutWriteRangeFromInput(input); ok {
|
||||
dr = dr.Set("writes_range", rng)
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := csvPutInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_range_from_csv", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rng, ok := csvPutWriteRangeFromInput(input); ok {
|
||||
if m, isMap := out.(map[string]interface{}); isMap {
|
||||
m["writes_range"] = rng
|
||||
}
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// csvPutWriteRangeFromInput computes the rectangle +csv-put will actually write,
|
||||
// from the built tool input (start_cell + csv). +csv-put pastes from the anchor
|
||||
// and auto-expands to the CSV's own row/column count — the footprint is the
|
||||
// result, not a user-set boundary. Surfacing it (e.g. "B2:D4") in dry-run and in
|
||||
// the success envelope lets agents see how far a paste reaches before it
|
||||
// silently overwrites neighbouring cells (use --allow-overwrite=false to block
|
||||
// that). Returns ok=false when the anchor is not a single cell or the CSV has no
|
||||
// parseable fields.
|
||||
func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
|
||||
anchor, _ := input["start_cell"].(string)
|
||||
csvText, _ := input["csv"].(string)
|
||||
if anchor == "" || csvText == "" {
|
||||
return "", false
|
||||
}
|
||||
col0, row0, ok := splitCellRef(anchor)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(csvText))
|
||||
r.FieldsPerRecord = -1 // tolerate ragged rows; we only need the max width
|
||||
records, err := r.ReadAll()
|
||||
if err != nil || len(records) == 0 {
|
||||
return "", false
|
||||
}
|
||||
cols := 0
|
||||
for _, rec := range records {
|
||||
if len(rec) > cols {
|
||||
cols = len(rec)
|
||||
}
|
||||
}
|
||||
if cols == 0 {
|
||||
return "", false
|
||||
}
|
||||
endCol := columnIndexToLetter(col0 + cols - 1)
|
||||
endRow := row0 + len(records) // row0 is 0-based; +len(records) is the 1-based bottom row
|
||||
return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true
|
||||
}
|
||||
|
||||
func csvPutInput(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("csv")) == "" {
|
||||
return nil, common.FlagErrorf("--csv is required")
|
||||
}
|
||||
anchor := strings.TrimSpace(runtime.Str("start-cell"))
|
||||
// --range is accepted as an alias for --start-cell. +csv-get and +cells-set
|
||||
// locate with --range, so agents routinely carry --range over to +csv-put and
|
||||
// hit a guaranteed first-try failure. Honor it when --start-cell was not
|
||||
// explicitly set — guard on Changed, not emptiness, because --start-cell
|
||||
// defaults to "A1" and is therefore never empty. A range like "A1:H17"
|
||||
// collapses to its top-left cell; +csv-put pastes from the anchor and
|
||||
// auto-expands, so the range's lower-right bound is irrelevant.
|
||||
//
|
||||
// Standalone enforces "one of --start-cell / --range" via cobra's
|
||||
// MarkFlagsOneRequired (see PostMount). A +batch-update sub-op never runs
|
||||
// cobra, so without an explicit check the default "A1" silently wins and the
|
||||
// paste lands at A1 instead of failing like the standalone command. Mirror
|
||||
// the standalone contract: when --start-cell is absent, --range is mandatory.
|
||||
if !runtime.Changed("start-cell") {
|
||||
rng := strings.TrimSpace(runtime.Str("range"))
|
||||
if rng == "" {
|
||||
return nil, common.FlagErrorf("--start-cell or --range is required")
|
||||
}
|
||||
anchor = strings.TrimSpace(strings.SplitN(rng, ":", 2)[0])
|
||||
}
|
||||
if anchor == "" {
|
||||
return nil, common.FlagErrorf("--start-cell is required")
|
||||
}
|
||||
if _, _, ok := splitCellRef(anchor); !ok {
|
||||
return nil, common.FlagErrorf("--start-cell %q must be a single cell ref (e.g. A1)", anchor)
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"csv": runtime.Str("csv"),
|
||||
"start_cell": anchor,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if !runtime.Bool("allow-overwrite") {
|
||||
input["allow_overwrite"] = false
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ─── +dropdown-* (set_cell_range via data_validation) ─────────────────
|
||||
//
|
||||
// All three dropdown shortcuts stamp a `data_validation` block on every cell
|
||||
// of the target range(s). set / update / delete differ in (a) how many
|
||||
// ranges they accept and (b) whether the block is populated or null.
|
||||
|
||||
// DropdownSet places a single dropdown on one range.
|
||||
var DropdownSet = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+dropdown-set",
|
||||
Description: "Attach a dropdown / data-validation list to every cell in --range.",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+dropdown-set"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateViaInput(dropdownSetInput)(ctx, runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
warnDropdownSourceRangeHighlight(runtime)
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
input, _ := dropdownSetInput(runtime, token, sheetID, sheetName)
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input, err := dropdownSetInput(runtime, token, sheetID, sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
|
||||
if err := requireSheetSelector(sheetID, sheetName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rangeStr := strings.TrimSpace(runtime.Str("range"))
|
||||
if rangeStr == "" {
|
||||
return nil, common.FlagErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(rangeStr)
|
||||
if err != nil {
|
||||
return nil, common.FlagErrorf("--range %q: %v", rangeStr, err)
|
||||
}
|
||||
validation, err := buildDropdownValidation(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells := fillCellsMatrix(rows, cols, map[string]interface{}{"data_validation": validation})
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": rangeStr,
|
||||
"cells": cells,
|
||||
}
|
||||
sheetSelectorForToolInput(input, sheetID, sheetName)
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// NOTE: +dropdown-update and +dropdown-delete were originally drafted here
|
||||
// but moved to lark_sheet_batch_update (B7) per the spec: multi-range
|
||||
// dropdown CRUD now goes through batch_update for atomicity. They'll land in
|
||||
// the batch_update file alongside +cells-batch-set-style.
|
||||
|
||||
// ─── shared dropdown helpers ──────────────────────────────────────────
|
||||
|
||||
// buildDropdownValidation packs --options or --source-range plus --colors /
|
||||
// --multiple / --highlight into the data_validation block expected by
|
||||
// set_cell_range. Field names follow the canonical
|
||||
// set_cell_range.data_validation schema:
|
||||
//
|
||||
// --options -> {type: "list", items: <strings>}
|
||||
// --source-range -> {type: "listFromRange", range: <A1+sheet prefix>}
|
||||
// --multiple -> support_multiple_values (bool)
|
||||
// --colors -> highlight_colors (string array, hex)
|
||||
// --highlight -> enable_highlight (bool, tri-state via Changed)
|
||||
//
|
||||
// --options and --source-range are XOR (caller must pass exactly one).
|
||||
// --colors length may be shorter than the source size (options length or
|
||||
// source-range cell count) — server cycles remaining slots through a
|
||||
// built-in 10-color palette — but must not exceed it.
|
||||
//
|
||||
// --highlight is tri-state: omitted leaves enable_highlight off the body so the
|
||||
// server's new default (true) applies; --highlight=true stamps an explicit true;
|
||||
// --highlight=false stamps false to turn the highlight off. Using Changed() lets
|
||||
// us distinguish "not passed" from "explicit false" — required because the
|
||||
// server-side default flipped from false to true and a plain cobra Bool can no
|
||||
// longer carry the opt-out signal.
|
||||
func buildDropdownValidation(runtime flagView) (map[string]interface{}, error) {
|
||||
sourceSize, dv, err := dropdownTypeAndItems(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if runtime.Str("colors") != "" {
|
||||
colors, err := requireJSONArray(runtime, "colors")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(colors) > sourceSize {
|
||||
return nil, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
}
|
||||
dv["highlight_colors"] = colors
|
||||
}
|
||||
if runtime.Bool("multiple") {
|
||||
dv["support_multiple_values"] = true
|
||||
}
|
||||
if runtime.Changed("highlight") {
|
||||
dv["enable_highlight"] = runtime.Bool("highlight")
|
||||
}
|
||||
return dv, nil
|
||||
}
|
||||
|
||||
// dropdownTypeAndItems resolves the XOR between --options and --source-range
|
||||
// and returns (sourceSize, partial dv with type+items|range set). sourceSize
|
||||
// is the option count for `list` mode or the source-range cell count for
|
||||
// `listFromRange` mode — used to validate --colors length.
|
||||
func dropdownTypeAndItems(runtime flagView) (int, map[string]interface{}, error) {
|
||||
optsRaw := runtime.Str("options")
|
||||
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
|
||||
switch {
|
||||
case optsRaw != "" && sourceRange != "":
|
||||
return 0, nil, common.FlagErrorf("--options and --source-range are mutually exclusive; pass exactly one")
|
||||
case optsRaw == "" && sourceRange == "":
|
||||
return 0, nil, common.FlagErrorf("one of --options (inline list) or --source-range (listFromRange) is required")
|
||||
case optsRaw != "":
|
||||
options, err := requireJSONArray(runtime, "options")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return len(options), map[string]interface{}{
|
||||
"type": "list",
|
||||
"items": options,
|
||||
}, nil
|
||||
default: // sourceRange != ""
|
||||
rows, cols, err := rangeDimensions(sourceRange)
|
||||
if err != nil {
|
||||
return 0, nil, common.FlagErrorf("--source-range %q: %v", sourceRange, err)
|
||||
}
|
||||
return rows * cols, map[string]interface{}{
|
||||
"type": "listFromRange",
|
||||
"range": sourceRange,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateDropdownSourceOrOptions runs the XOR + --colors length check at
|
||||
// Validate time so +dropdown-update / +dropdown-delete can fail fast without
|
||||
// reaching the body-build step. Returns the dropdown source size (options
|
||||
// length for list mode, source-range cell count for listFromRange) so
|
||||
// callers can size their cells matrix.
|
||||
func validateDropdownSourceOrOptions(runtime flagView) (int, error) {
|
||||
sourceSize, _, err := dropdownTypeAndItems(runtime)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if runtime.Str("colors") != "" {
|
||||
colors, err := requireJSONArray(runtime, "colors")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(colors) > sourceSize {
|
||||
return 0, common.FlagErrorf("--colors length (%d) must not exceed dropdown source size (%d)", len(colors), sourceSize)
|
||||
}
|
||||
}
|
||||
return sourceSize, nil
|
||||
}
|
||||
|
||||
// dropdownSourceRangeHighlightLimit is the cell-count cap above which the
|
||||
// server marks the dropdown's options as invalid when highlight is on.
|
||||
// Source: byted-sheet core LIST_WITH_COLOR_MAX_COUNT
|
||||
// (sheet-packages/.../dataValidation/list/ListFromRangeValidation.ts:49).
|
||||
// Beyond this, ListFromRangeValidation.checkOptionsValid() sets
|
||||
// isOptionError=true (highlight + range > 2000 is an unsupported combo).
|
||||
const dropdownSourceRangeHighlightLimit = 2000
|
||||
|
||||
// warnDropdownSourceRangeHighlight emits a soft stderr warning when the user
|
||||
// targets a --source-range larger than dropdownSourceRangeHighlightLimit while
|
||||
// highlight is on (the server-side default and the most common path).
|
||||
// Inline --options is not subject to this limit (server has no inline count
|
||||
// or per-item length cap; only the listFromRange + highlight combo is).
|
||||
// Validate phase only — never blocks the request. Caller must already have
|
||||
// confirmed the source-or-options validation passed.
|
||||
func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
|
||||
sourceRange := strings.TrimSpace(runtime.Str("source-range"))
|
||||
if sourceRange == "" {
|
||||
return // inline --options mode — no server-side size cap applies
|
||||
}
|
||||
// highlight is tri-state: omitted = ON (server default), --highlight=true
|
||||
// = ON, --highlight=false = OFF. Only the OFF case avoids the warning.
|
||||
if runtime.Changed("highlight") && !runtime.Bool("highlight") {
|
||||
return
|
||||
}
|
||||
rows, cols, err := rangeDimensions(sourceRange)
|
||||
if err != nil {
|
||||
return // already errored upstream; don't double-report
|
||||
}
|
||||
cellCount := rows * cols
|
||||
if cellCount <= dropdownSourceRangeHighlightLimit {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"warning: --source-range covers %d cells; server marks the dropdown as option-error when highlight is on and the source exceeds %d cells. Pass --highlight=false to suppress this.\n",
|
||||
cellCount, dropdownSourceRangeHighlightLimit)
|
||||
}
|
||||
|
||||
// ─── range parsing helpers ────────────────────────────────────────────
|
||||
|
||||
// rangeDimensions parses an A1 range like "A1:C5" / "A1" / "sheet1!B2:D10"
|
||||
// and returns its row / column counts. Errors on non-rectangular forms like
|
||||
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
|
||||
// from get_sheet_structure, outside the scope of pure local parsing.
|
||||
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
|
||||
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
|
||||
rangeStr = rangeStr[idx+1:]
|
||||
}
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
return 0, 0, fmt.Errorf("empty range")
|
||||
}
|
||||
parts := strings.SplitN(rangeStr, ":", 2)
|
||||
if len(parts) == 1 {
|
||||
// single cell, e.g. "A1"
|
||||
if _, _, ok := splitCellRef(parts[0]); !ok {
|
||||
return 0, 0, fmt.Errorf("invalid cell ref %q", parts[0])
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
startCol, startRow, ok1 := splitCellRef(parts[0])
|
||||
endCol, endRow, ok2 := splitCellRef(parts[1])
|
||||
if !ok1 || !ok2 {
|
||||
return 0, 0, fmt.Errorf("unsupported range form %q (need rectangular A1:B2)", rangeStr)
|
||||
}
|
||||
if endRow < startRow || endCol < startCol {
|
||||
return 0, 0, fmt.Errorf("end %q must be at or after start %q", parts[1], parts[0])
|
||||
}
|
||||
return endRow - startRow + 1, endCol - startCol + 1, nil
|
||||
}
|
||||
|
||||
// splitCellRef parses "A1" → (col=0, row=0, true). Returns false for any
|
||||
// non-rectangular form (pure column "A", pure row "1", invalid chars).
|
||||
func splitCellRef(s string) (col, row int, ok bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, 0, false
|
||||
}
|
||||
var colEnd int
|
||||
for i, r := range s {
|
||||
if r >= '0' && r <= '9' {
|
||||
colEnd = i
|
||||
break
|
||||
}
|
||||
colEnd = i + 1
|
||||
}
|
||||
if colEnd == 0 || colEnd == len(s) {
|
||||
return 0, 0, false
|
||||
}
|
||||
col = letterToColumnIndex(s[:colEnd])
|
||||
if col < 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
n, err := strconv.Atoi(s[colEnd:])
|
||||
if err != nil || n < 1 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return col, n - 1, true
|
||||
}
|
||||
|
||||
// letterToColumnIndex converts spreadsheet letter notation to a 0-based
|
||||
// column index ("A" → 0, "Z" → 25, "AA" → 26). Returns -1 on bad input.
|
||||
func letterToColumnIndex(letters string) int {
|
||||
letters = strings.ToUpper(strings.TrimSpace(letters))
|
||||
if letters == "" {
|
||||
return -1
|
||||
}
|
||||
n := 0
|
||||
for _, c := range letters {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return -1
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
|
||||
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
|
||||
// single attribute (style / data_validation) across an entire range.
|
||||
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
|
||||
cells := make([][]interface{}, rows)
|
||||
for r := range cells {
|
||||
row := make([]interface{}, cols)
|
||||
for c := range row {
|
||||
cell := make(map[string]interface{}, len(prototype))
|
||||
for k, v := range prototype {
|
||||
cell[k] = v
|
||||
}
|
||||
row[c] = cell
|
||||
}
|
||||
cells[r] = row
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
// ─── +cells-set-image (cli_only_derivative) ──────────────────────────
|
||||
//
|
||||
// The backing tool (set_cell_range) is in mcp-tools.json, but the CLI
|
||||
// shortcut also needs a local-file upload before it can call the tool.
|
||||
// That extra step doesn't fit the One-OpenAPI dispatcher, so the spec
|
||||
// marks this shortcut cli_only_derivative — the CLI uploads the image
|
||||
// to drive (parent_type=sheet_image) and then writes the returned
|
||||
// file_token into the target cell via callTool(set_cell_range) with a
|
||||
// rich_text embed-image entry.
|
||||
|
||||
// CellsSetImage uploads a local image to drive (parent_type=sheet_image,
|
||||
// parent_node=spreadsheet token) and then writes a rich_text embed-image
|
||||
// into the target single-cell range via the set_cell_range tool.
|
||||
var CellsSetImage = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+cells-set-image",
|
||||
Description: "Embed a local image into a single cell (uploads via drive, then set_cell_range with rich_text embed-image).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only", "drive:file:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: flagsFor("+cells-set-image"),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := resolveSheetSelector(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
r := strings.TrimSpace(runtime.Str("range"))
|
||||
if r == "" {
|
||||
return common.FlagErrorf("--range is required")
|
||||
}
|
||||
rows, cols, err := rangeDimensions(r)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--range %q: %v", r, err)
|
||||
}
|
||||
if rows != 1 || cols != 1 {
|
||||
return common.FlagErrorf("--range %q must be exactly one cell (got %d×%d)", r, rows, cols)
|
||||
}
|
||||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||||
if imgPath == "" {
|
||||
return common.FlagErrorf("--image is required")
|
||||
}
|
||||
// Validate path safety here (not just at Execute) so --dry-run also
|
||||
// rejects unsafe paths instead of giving a false-positive preview.
|
||||
// SafeLocalFlagPath checks path safety only (abs/traversal/outside-cwd),
|
||||
// not existence, so legitimate relative paths still dry-run cleanly;
|
||||
// the Execute-time Stat below still reports a missing/unreadable file.
|
||||
if _, err := validate.SafeLocalFlagPath("--image", imgPath); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, _ := resolveSpreadsheetToken(runtime)
|
||||
sheetID, sheetName, _ := resolveSheetSelector(runtime)
|
||||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(imgPath)
|
||||
}
|
||||
setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"sheet_id": sheetSelectorPlaceholder(sheetID, sheetName),
|
||||
"cells": [][]interface{}{{map[string]interface{}{
|
||||
"rich_text": []map[string]interface{}{{
|
||||
"type": "embed-image",
|
||||
"text": "",
|
||||
"image_token": "<file_token>",
|
||||
"image_width": "<image_width>",
|
||||
"image_height": "<image_height>",
|
||||
}},
|
||||
}}},
|
||||
})
|
||||
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": fileName,
|
||||
"parent_type": "sheet_image",
|
||||
"parent_node": token,
|
||||
"size": "<file_size>",
|
||||
"file": "@" + imgPath,
|
||||
}).
|
||||
POST(toolInvokePath(token, ToolKindWrite)).
|
||||
Desc("embed file_token into the cell via set_cell_range").
|
||||
Body(setCellBody)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetID, sheetName, err := resolveSheetSelector(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgPath := strings.TrimSpace(runtime.Str("image"))
|
||||
fileName := strings.TrimSpace(runtime.Str("name"))
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(imgPath)
|
||||
}
|
||||
info, err := runtime.FileIO().Stat(imgPath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
imgFile, err := runtime.FileIO().Open(imgPath)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err)
|
||||
}
|
||||
imgCfg, _, err := image.DecodeConfig(imgFile)
|
||||
imgFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image dimensions: %w", err)
|
||||
}
|
||||
fileToken, err := common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: imgPath,
|
||||
FileName: fileName,
|
||||
FileSize: info.Size(),
|
||||
ParentType: "sheet_image",
|
||||
ParentNode: &token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
setCellInput := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
"cells": [][]interface{}{{map[string]interface{}{
|
||||
"rich_text": []map[string]interface{}{{
|
||||
"type": "embed-image",
|
||||
"text": "",
|
||||
"image_token": fileToken,
|
||||
"image_width": imgCfg.Width,
|
||||
"image_height": imgCfg.Height,
|
||||
}},
|
||||
}}},
|
||||
}
|
||||
sheetSelectorForToolInput(setCellInput, sheetID, sheetName)
|
||||
setCellOut, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", setCellInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("image uploaded (file_token=%s) but cell write failed: %w", fileToken, err)
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"file_token": fileToken,
|
||||
"file_name": fileName,
|
||||
"set_cell_range": setCellOut,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"--range must be a single cell. The uploaded image becomes a cell-internal embed; use +float-image-create for floating images.",
|
||||
},
|
||||
}
|
||||
542
shortcuts/sheets/lark_sheet_write_cells_test.go
Normal file
542
shortcuts/sheets/lark_sheet_write_cells_test.go
Normal file
@@ -0,0 +1,542 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWriteCellsShortcuts_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
toolName string
|
||||
wantInput map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "+cells-set with --cells bare 2D array",
|
||||
sc: CellsSet,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:B2",
|
||||
"--cells", `[[{"value":1},{"value":2}],[{"value":3},{"value":4}]]`,
|
||||
},
|
||||
toolName: "set_cell_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:B2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}, map[string]interface{}{"value": float64(2)}}, []interface{}{map[string]interface{}{"value": float64(3)}, map[string]interface{}{"value": float64(4)}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-set --allow-overwrite=false sends false explicitly",
|
||||
sc: CellsSet,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1",
|
||||
"--cells", `[[{"value":1}]]`,
|
||||
"--allow-overwrite=false",
|
||||
},
|
||||
toolName: "set_cell_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"value": float64(1)}}},
|
||||
"allow_overwrite": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+cells-set --copy-to-range passes copy_to_range",
|
||||
sc: CellsSet,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "H2",
|
||||
"--cells", `[[{"formula":"=A2*B2"}]]`,
|
||||
"--copy-to-range", "H2:H100",
|
||||
},
|
||||
toolName: "set_cell_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "H2",
|
||||
"cells": []interface{}{[]interface{}{map[string]interface{}{"formula": "=A2*B2"}}},
|
||||
"copy_to_range": "H2:H100",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+csv-put inline csv",
|
||||
sc: CsvPut,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--csv", "a,b,c\n1,2,3",
|
||||
"--start-cell", "B3",
|
||||
},
|
||||
toolName: "set_range_from_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"csv": "a,b,c\n1,2,3",
|
||||
"start_cell": "B3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+dropdown-set fans out cells matrix",
|
||||
sc: DropdownSet,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A2:A4",
|
||||
"--options", `["a","b"]`,
|
||||
"--multiple",
|
||||
},
|
||||
toolName: "set_cell_range",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A2:A4",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, tt.sc, tt.args)
|
||||
got := decodeToolInput(t, body, tt.toolName)
|
||||
assertInputEquals(t, got, tt.wantInput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_CellsShape inspects the 3×1 matrix produced from
|
||||
// --range A2:A4 to confirm the data_validation prototype is replicated.
|
||||
// Also covers --colors / --highlight emitting the canonical
|
||||
// `highlight_colors` / `enable_highlight` field names (not the legacy
|
||||
// `colors` / `highlight_options`).
|
||||
func TestDropdownSet_CellsShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A2:A4", "--options", `["a","b"]`, "--multiple",
|
||||
"--colors", `["#FFE699","#bff7d9"]`, "--highlight",
|
||||
})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("cells rows = %d, want 3 (A2:A4)", len(cells))
|
||||
}
|
||||
for i, row := range cells {
|
||||
r, _ := row.([]interface{})
|
||||
if len(r) != 1 {
|
||||
t.Errorf("row %d cols = %d, want 1", i, len(r))
|
||||
}
|
||||
cell, _ := r[0].(map[string]interface{})
|
||||
dv, _ := cell["data_validation"].(map[string]interface{})
|
||||
if dv == nil {
|
||||
t.Errorf("row %d cell missing data_validation: %#v", i, cell)
|
||||
continue
|
||||
}
|
||||
if dv["type"] != "list" {
|
||||
t.Errorf("row %d data_validation.type = %v, want list", i, dv["type"])
|
||||
}
|
||||
items, _ := dv["items"].([]interface{})
|
||||
if len(items) != 2 || items[0] != "a" || items[1] != "b" {
|
||||
t.Errorf("row %d data_validation.items = %#v, want [\"a\",\"b\"]", i, dv["items"])
|
||||
}
|
||||
if dv["support_multiple_values"] != true {
|
||||
t.Errorf("row %d data_validation.support_multiple_values = %v, want true", i, dv["support_multiple_values"])
|
||||
}
|
||||
if _, hasLegacy := dv["multiple_values"]; hasLegacy {
|
||||
t.Errorf("row %d data_validation should not emit legacy `multiple_values`", i)
|
||||
}
|
||||
colors, _ := dv["highlight_colors"].([]interface{})
|
||||
if len(colors) != 2 || colors[0] != "#FFE699" || colors[1] != "#bff7d9" {
|
||||
t.Errorf("row %d data_validation.highlight_colors = %#v, want [\"#FFE699\",\"#bff7d9\"]", i, dv["highlight_colors"])
|
||||
}
|
||||
if dv["enable_highlight"] != true {
|
||||
t.Errorf("row %d data_validation.enable_highlight = %v, want true", i, dv["enable_highlight"])
|
||||
}
|
||||
if _, hasLegacy := dv["colors"]; hasLegacy {
|
||||
t.Errorf("row %d data_validation should not emit legacy `colors`", i)
|
||||
}
|
||||
if _, hasLegacy := dv["highlight_options"]; hasLegacy {
|
||||
t.Errorf("row %d data_validation should not emit legacy `highlight_options`", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_HighlightTriState pins down the tri-state semantics of
|
||||
// --highlight after the server flipped enable_highlight's default from false
|
||||
// to true. The translator uses runtime.Changed() to tell "user did not pass
|
||||
// the flag" apart from "user passed --highlight=false":
|
||||
//
|
||||
// - omitted → no enable_highlight key in body (server applies its
|
||||
// new default = true)
|
||||
// - --highlight → enable_highlight=true (presence-only cobra form)
|
||||
// - --highlight=true → enable_highlight=true (explicit form)
|
||||
// - --highlight=false → enable_highlight=false (the only way to opt out;
|
||||
// the documented "plain dropdown" path)
|
||||
func TestDropdownSet_HighlightTriState(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantKey bool
|
||||
wantValue bool
|
||||
}{
|
||||
{
|
||||
name: "omitted leaves enable_highlight off the body",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`},
|
||||
wantKey: false,
|
||||
},
|
||||
{
|
||||
name: "presence form (--highlight) stamps true",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight"},
|
||||
wantKey: true,
|
||||
wantValue: true,
|
||||
},
|
||||
{
|
||||
name: "explicit --highlight=true stamps true",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=true"},
|
||||
wantKey: true,
|
||||
wantValue: true,
|
||||
},
|
||||
{
|
||||
name: "explicit --highlight=false stamps false (the opt-out path)",
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A2", "--options", `["a","b"]`, "--highlight=false"},
|
||||
wantKey: true,
|
||||
wantValue: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, DropdownSet, tc.args)
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
cell, _ := row0[0].(map[string]interface{})
|
||||
dv, _ := cell["data_validation"].(map[string]interface{})
|
||||
got, has := dv["enable_highlight"]
|
||||
if has != tc.wantKey {
|
||||
t.Fatalf("enable_highlight key present = %v, want %v (dv = %#v)", has, tc.wantKey, dv)
|
||||
}
|
||||
if tc.wantKey && got != tc.wantValue {
|
||||
t.Errorf("enable_highlight = %v (%T), want %v", got, got, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_ColorsLongerThanOptions checks the early Validate-time
|
||||
// error when --colors length exceeds the dropdown source size (options
|
||||
// length in list mode). Equal-or-shorter lengths are accepted (server
|
||||
// cycles the rest through a built-in palette).
|
||||
func TestDropdownSet_ColorsLongerThanOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A2:A4",
|
||||
"--options", `["a","b"]`,
|
||||
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected --colors length error, got nil")
|
||||
}
|
||||
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
|
||||
t.Errorf("error message missing length-overflow hint:\nerr=%v\nstderr=%s", err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_ColorsShorterAccepted verifies the partial-colors case:
|
||||
// fewer colors than options is legal — array is forwarded as-is and the
|
||||
// server fills remaining slots from its default palette.
|
||||
func TestDropdownSet_ColorsShorterAccepted(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A2:A4",
|
||||
"--options", `["a","b","c","d"]`,
|
||||
"--colors", `["#FFE699","#bff7d9"]`,
|
||||
})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
cell, _ := row0[0].(map[string]interface{})
|
||||
dv, _ := cell["data_validation"].(map[string]interface{})
|
||||
colors, _ := dv["highlight_colors"].([]interface{})
|
||||
if len(colors) != 2 {
|
||||
t.Errorf("highlight_colors length = %d, want 2 (forwarded as-is)", len(colors))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_ListFromRange verifies --source-range emits
|
||||
// data_validation.type=listFromRange + data_validation.range, paired with
|
||||
// --colors / --highlight propagating to highlight_colors / enable_highlight.
|
||||
func TestDropdownSet_ListFromRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "B2:B21",
|
||||
"--source-range", "Sheet1!T1:T3",
|
||||
"--colors", `["#cce8ff","#ffd6e7","#e6e6e6"]`,
|
||||
"--highlight",
|
||||
})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
cell, _ := row0[0].(map[string]interface{})
|
||||
dv, _ := cell["data_validation"].(map[string]interface{})
|
||||
if dv["type"] != "listFromRange" {
|
||||
t.Errorf("data_validation.type = %v, want listFromRange", dv["type"])
|
||||
}
|
||||
if dv["range"] != "Sheet1!T1:T3" {
|
||||
t.Errorf("data_validation.range = %v, want Sheet1!T1:T3 (verbatim, server normalizes)", dv["range"])
|
||||
}
|
||||
if _, hasItems := dv["items"]; hasItems {
|
||||
t.Errorf("listFromRange mode should not emit `items`: %#v", dv)
|
||||
}
|
||||
if dv["enable_highlight"] != true {
|
||||
t.Errorf("data_validation.enable_highlight = %v, want true", dv["enable_highlight"])
|
||||
}
|
||||
colors, _ := dv["highlight_colors"].([]interface{})
|
||||
if len(colors) != 3 {
|
||||
t.Errorf("highlight_colors length = %d, want 3", len(colors))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_ListFromRange_ColorsLongerThanCells rejects --colors
|
||||
// longer than the source range cell count (T1:T3 has 3 cells, 4 colors
|
||||
// must be refused).
|
||||
func TestDropdownSet_ListFromRange_ColorsLongerThanCells(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "B2:B21",
|
||||
"--source-range", "Sheet1!T1:T3",
|
||||
"--colors", `["#a","#b","#c","#d"]`,
|
||||
"--highlight",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected --colors length error, got nil")
|
||||
}
|
||||
if !strings.Contains(stderr, "must not exceed dropdown source size") && !strings.Contains(err.Error(), "must not exceed dropdown source size") {
|
||||
t.Errorf("error message missing source-size hint:\nerr=%v\nstderr=%s", err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_XorBothSet rejects passing both --options and
|
||||
// --source-range.
|
||||
func TestDropdownSet_XorBothSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "B2:B21",
|
||||
"--options", `["a","b"]`,
|
||||
"--source-range", "Sheet1!T1:T3",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected XOR error, got nil")
|
||||
}
|
||||
if !strings.Contains(stderr, "mutually exclusive") && !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("error message missing XOR hint:\nerr=%v\nstderr=%s", err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDropdownSet_XorNeitherSet rejects passing neither --options nor
|
||||
// --source-range.
|
||||
func TestDropdownSet_XorNeitherSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, DropdownSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "B2:B21",
|
||||
"--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected required-one error, got nil")
|
||||
}
|
||||
if !strings.Contains(stderr, "one of --options") && !strings.Contains(err.Error(), "one of --options") {
|
||||
t.Errorf("error message missing required-one hint:\nerr=%v\nstderr=%s", err, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetStyle_FlatFlags verifies that the 11 flat style flags +
|
||||
// --border-styles compose into cell_styles + border_styles per cell.
|
||||
func TestCellsSetStyle_FlatFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, CellsSetStyle, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:B1",
|
||||
"--font-weight", "bold",
|
||||
"--background-color", "#ffff00",
|
||||
"--horizontal-alignment", "center",
|
||||
"--border-styles", `{"top":{"style":"solid"}}`,
|
||||
})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row, _ := cells[0].([]interface{})
|
||||
cell, _ := row[0].(map[string]interface{})
|
||||
style, _ := cell["cell_styles"].(map[string]interface{})
|
||||
if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" || style["horizontal_alignment"] != "center" {
|
||||
t.Errorf("cell_styles wrong: %#v", style)
|
||||
}
|
||||
if cell["border_styles"] == nil {
|
||||
t.Fatalf("border_styles missing on cell: %#v", cell)
|
||||
}
|
||||
if _, leaked := style["border_styles"]; leaked {
|
||||
t.Errorf("border_styles leaked into cell_styles: %#v", style)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsSetStyle_RequiresAtLeastOneFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetStyle, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:B2", "--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "at least one style flag") {
|
||||
t.Errorf("expected style-flag guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsSet_RequiresJSONArray(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", `{"foo":"bar"}`, "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
// Schema validator fires first now: "--cells: expected type \"array\", got \"object\"".
|
||||
// Either the schema phrasing or the legacy requireJSONArray phrasing is
|
||||
// acceptable — both pin the same contract.
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, `expected type "array"`) && !strings.Contains(combined, "must be a JSON array") {
|
||||
t.Errorf("expected array-type guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
|
||||
// rendered, including the parent_type=sheet_image upload metadata.
|
||||
func TestCellsSetImage_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, CellsSetImage, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1",
|
||||
"--image", "./README.md", // any existing-shaped path; dry-run skips stat
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls))
|
||||
}
|
||||
upload := calls[0].(map[string]interface{})
|
||||
if upload["url"] != "/open-apis/drive/v1/medias/upload_all" {
|
||||
t.Errorf("upload url = %v", upload["url"])
|
||||
}
|
||||
ubody, _ := upload["body"].(map[string]interface{})
|
||||
if ubody["parent_type"] != "sheet_image" {
|
||||
t.Errorf("parent_type = %v, want sheet_image", ubody["parent_type"])
|
||||
}
|
||||
if ubody["parent_node"] != testToken {
|
||||
t.Errorf("parent_node = %v, want token", ubody["parent_node"])
|
||||
}
|
||||
|
||||
embed := calls[1].(map[string]interface{})
|
||||
body, _ := embed["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row, _ := cells[0].([]interface{})
|
||||
cell, _ := row[0].(map[string]interface{})
|
||||
rt, _ := cell["rich_text"].([]interface{})
|
||||
if len(rt) != 1 {
|
||||
t.Fatalf("rich_text len = %d, want 1", len(rt))
|
||||
}
|
||||
item, _ := rt[0].(map[string]interface{})
|
||||
if item["type"] != "embed-image" {
|
||||
t.Errorf("rich_text.type = %v, want embed-image", item["type"])
|
||||
}
|
||||
if item["image_token"] != "<file_token>" {
|
||||
t.Errorf("image_token = %v, want <file_token>", item["image_token"])
|
||||
}
|
||||
if item["text"] != "" {
|
||||
t.Errorf("text = %v, want empty string", item["text"])
|
||||
}
|
||||
if item["image_width"] != "<image_width>" {
|
||||
t.Errorf("image_width = %v, want <image_width>", item["image_width"])
|
||||
}
|
||||
if item["image_height"] != "<image_height>" {
|
||||
t.Errorf("image_height = %v, want <image_height>", item["image_height"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:B2", "--image", "./foo.png", "--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be exactly one cell") {
|
||||
t.Errorf("expected single-cell guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRunRejectsUnsafePath guards that an unsafe --image path
|
||||
// (e.g. an absolute path) is rejected during Validate, so --dry-run fails the
|
||||
// same way as a real run instead of printing a misleading success preview.
|
||||
func TestCellsSetImage_DryRunRejectsUnsafePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSetImage, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--image", "/etc/hosts", "--dry-run",
|
||||
})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must be a relative path") {
|
||||
t.Errorf("expected unsafe-path guard during dry-run; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRangeDimensions exercises the A1 parser's corner cases used by
|
||||
// cells-set-style / dropdown-set / rows-resize / cols-resize.
|
||||
func TestRangeDimensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
wantRows int
|
||||
wantCols int
|
||||
wantErr bool
|
||||
}{
|
||||
{"A1", 1, 1, false},
|
||||
{"A1:B2", 2, 2, false},
|
||||
{"sheet1!C3:E10", 8, 3, false},
|
||||
{"A:C", 0, 0, true}, // whole column not supported
|
||||
{"3:6", 0, 0, true}, // whole row not supported
|
||||
{"B2:A1", 0, 0, true}, // end before start
|
||||
{"", 0, 0, true},
|
||||
}
|
||||
var unusedSheet common.Shortcut = CellsSet // touch the common import
|
||||
_ = unusedSheet
|
||||
for _, c := range cases {
|
||||
rows, cols, err := rangeDimensions(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("rangeDimensions(%q): want error, got rows=%d cols=%d", c.in, rows, cols)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("rangeDimensions(%q) unexpected error: %v", c.in, err)
|
||||
}
|
||||
if rows != c.wantRows || cols != c.wantCols {
|
||||
t.Errorf("rangeDimensions(%q) = (%d,%d), want (%d,%d)", c.in, rows, cols, c.wantRows, c.wantCols)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
shortcuts/sheets/sheet_ai_api.go
Normal file
119
shortcuts/sheets/sheet_ai_api.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket.
|
||||
//
|
||||
// - ToolKindRead → POST .../tools/invoke_read (scope sheets:spreadsheet:read, 10 qps)
|
||||
// - ToolKindWrite → POST .../tools/invoke_write (scope sheets:spreadsheet:write_only, 5 qps)
|
||||
type ToolKind string
|
||||
|
||||
const (
|
||||
ToolKindRead ToolKind = "read"
|
||||
ToolKindWrite ToolKind = "write"
|
||||
)
|
||||
|
||||
// toolInvokePath returns the full One-OpenAPI invoke path for the given
|
||||
// spreadsheet token + tool kind. Network-free, safe in DryRun.
|
||||
func toolInvokePath(token string, kind ToolKind) string {
|
||||
suffix := "invoke_read"
|
||||
if kind == ToolKindWrite {
|
||||
suffix = "invoke_write"
|
||||
}
|
||||
return fmt.Sprintf("/open-apis/sheet_ai/v2/spreadsheets/%s/tools/%s",
|
||||
validate.EncodePathSegment(token), suffix)
|
||||
}
|
||||
|
||||
// buildToolBody constructs the One-OpenAPI request body for a tool invocation.
|
||||
// `input` is serialized to a JSON string per the API contract; callers pass
|
||||
// a typed Go map and never need to handle JSON encoding themselves.
|
||||
func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
inputJSON, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode tool input: %w", err)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"tool_name": toolName,
|
||||
"input": string(inputJSON),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes
|
||||
// the JSON-string `output` field into a generic Go value (typically
|
||||
// map[string]interface{}). When the tool returns an empty `output`, callTool
|
||||
// returns nil with no error.
|
||||
//
|
||||
// kind must match the tool's read/write classification — passing a read tool
|
||||
// to invoke_write (or vice versa) results in a 403 from the gateway.
|
||||
func callTool(
|
||||
ctx context.Context,
|
||||
runtime *common.RuntimeContext,
|
||||
token string,
|
||||
kind ToolKind,
|
||||
toolName string,
|
||||
input map[string]interface{},
|
||||
) (interface{}, error) {
|
||||
body, err := buildToolBody(toolName, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := runtime.RawAPI("POST", toolInvokePath(token, kind), nil, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
envelope, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, output.Errorf(output.ExitAPI, "tool_response",
|
||||
"tool %q: unexpected non-JSON-object response: %v", toolName, raw)
|
||||
}
|
||||
code, _ := util.ToFloat64(envelope["code"])
|
||||
if code != 0 {
|
||||
msg, _ := envelope["msg"].(string)
|
||||
return nil, output.ErrAPI(int(code), fmt.Sprintf("tool %q failed: [%d] %s", toolName, int(code), msg), envelope["error"])
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
rawOutput, _ := data["output"].(string)
|
||||
if rawOutput == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(rawOutput), &out); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "tool_output",
|
||||
"tool %q returned invalid JSON output: %v", toolName, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
|
||||
// The wire-format body (with input serialized to a JSON string) is preserved
|
||||
// for fidelity, and a decoded tool_input map is surfaced alongside so humans
|
||||
// don't have to mentally unmarshal the string field.
|
||||
func invokeToolDryRun(
|
||||
token string,
|
||||
kind ToolKind,
|
||||
toolName string,
|
||||
input map[string]interface{},
|
||||
) *common.DryRunAPI {
|
||||
wireBody, _ := buildToolBody(toolName, input)
|
||||
return common.NewDryRunAPI().
|
||||
POST(toolInvokePath(token, kind)).
|
||||
Body(wireBody).
|
||||
Set("spreadsheet_token", token).
|
||||
Set("tool_name", toolName).
|
||||
Set("tool_input", input)
|
||||
}
|
||||
@@ -5,67 +5,103 @@ package sheets
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all sheets shortcuts.
|
||||
// Shortcuts returns all lark-sheets shortcuts. The list is grouped by
|
||||
// canonical skill to mirror the sheet-skill-spec layout
|
||||
// (lark_sheet_workbook → lark_sheet_float_image).
|
||||
//
|
||||
// Any shortcut whose command is registered in data/flag-schemas.json gets a
|
||||
// PrintFlagSchema closure attached, so the framework can serve
|
||||
// `--print-schema --flag-name <name>` locally.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
all := shortcutList()
|
||||
// Gate on the codegen'd command set (flag_schemas_gen.go) so registration
|
||||
// — which runs on every CLI invocation — does not parse the 256KB
|
||||
// flag-schemas.json. The blob is unmarshaled lazily (printFlagSchemaFor /
|
||||
// the validate fast-path) only when actually needed.
|
||||
for i := range all {
|
||||
if _, ok := commandsWithSchema[all[i].Command]; ok {
|
||||
all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func shortcutList() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
// Spreadsheet management
|
||||
// lark_sheet_workbook
|
||||
WorkbookInfo,
|
||||
SheetCreate,
|
||||
SheetDelete,
|
||||
SheetRename,
|
||||
SheetMove,
|
||||
SheetCopy,
|
||||
SheetHide,
|
||||
SheetUnhide,
|
||||
SheetSetTabColor,
|
||||
WorkbookCreate,
|
||||
WorkbookExport,
|
||||
|
||||
// lark_sheet_sheet_structure
|
||||
SheetInfo,
|
||||
SheetExport,
|
||||
DimInsert,
|
||||
DimDelete,
|
||||
DimHide,
|
||||
DimUnhide,
|
||||
DimFreeze,
|
||||
DimGroup,
|
||||
DimUngroup,
|
||||
DimMove,
|
||||
|
||||
// Sheet management
|
||||
SheetCreateSheet,
|
||||
SheetCopySheet,
|
||||
SheetDeleteSheet,
|
||||
SheetUpdateSheet,
|
||||
// lark_sheet_read_data
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
DropdownGet,
|
||||
|
||||
// Cell data
|
||||
SheetRead,
|
||||
SheetWrite,
|
||||
SheetAppend,
|
||||
SheetFind,
|
||||
SheetReplace,
|
||||
// lark_sheet_search_replace
|
||||
CellsSearch,
|
||||
CellsReplace,
|
||||
|
||||
// Cell style and merge
|
||||
SheetSetStyle,
|
||||
SheetBatchSetStyle,
|
||||
SheetMergeCells,
|
||||
SheetUnmergeCells,
|
||||
// lark_sheet_write_cells
|
||||
CellsSet,
|
||||
CellsSetStyle,
|
||||
CellsSetImage,
|
||||
CsvPut,
|
||||
DropdownSet,
|
||||
|
||||
// Cell images
|
||||
SheetWriteImage,
|
||||
// lark_sheet_range_operations
|
||||
CellsClear,
|
||||
CellsMerge,
|
||||
CellsUnmerge,
|
||||
RowsResize,
|
||||
ColsResize,
|
||||
RangeMove,
|
||||
RangeCopy,
|
||||
RangeFill,
|
||||
RangeSort,
|
||||
|
||||
// Row/column management
|
||||
SheetAddDimension,
|
||||
SheetInsertDimension,
|
||||
SheetUpdateDimension,
|
||||
SheetMoveDimension,
|
||||
SheetDeleteDimension,
|
||||
// Object list (one read shortcut per object skill)
|
||||
ChartList,
|
||||
PivotList,
|
||||
CondFormatList,
|
||||
FilterList,
|
||||
FilterViewList,
|
||||
SparklineList,
|
||||
FloatImageList,
|
||||
|
||||
// Filter views
|
||||
SheetCreateFilterView,
|
||||
SheetUpdateFilterView,
|
||||
SheetListFilterViews,
|
||||
SheetGetFilterView,
|
||||
SheetDeleteFilterView,
|
||||
SheetCreateFilterViewCondition,
|
||||
SheetUpdateFilterViewCondition,
|
||||
SheetListFilterViewConditions,
|
||||
SheetGetFilterViewCondition,
|
||||
SheetDeleteFilterViewCondition,
|
||||
// Object CRUD (3 per skill)
|
||||
ChartCreate, ChartUpdate, ChartDelete,
|
||||
PivotCreate, PivotUpdate, PivotDelete,
|
||||
CondFormatCreate, CondFormatUpdate, CondFormatDelete,
|
||||
FilterCreate, FilterUpdate, FilterDelete,
|
||||
FilterViewCreate, FilterViewUpdate, FilterViewDelete,
|
||||
SparklineCreate, SparklineUpdate, SparklineDelete,
|
||||
FloatImageCreate, FloatImageUpdate, FloatImageDelete,
|
||||
|
||||
// Dropdown
|
||||
SheetSetDropdown,
|
||||
SheetUpdateDropdown,
|
||||
SheetGetDropdown,
|
||||
SheetDeleteDropdown,
|
||||
|
||||
// Float images
|
||||
SheetMediaUpload,
|
||||
SheetCreateFloatImage,
|
||||
SheetUpdateFloatImage,
|
||||
SheetGetFloatImage,
|
||||
SheetListFloatImages,
|
||||
SheetDeleteFloatImage,
|
||||
// lark_sheet_batch_update
|
||||
BatchUpdate,
|
||||
CellsBatchSetStyle,
|
||||
CellsBatchClear,
|
||||
DropdownUpdate,
|
||||
DropdownDelete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +1,156 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 1.2.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、创建/复制/删除/更新工作表、读写单元格、追加行数据、查找内容、导出文件。当用户需要创建电子表格、管理工作表、批量读写数据、在已知表格中查找内容、导出或下载表格时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
version: 2.0.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
siblings: ["lark-shared"]
|
||||
cliHelp: "lark-cli sheets --help"
|
||||
---
|
||||
|
||||
# sheets (v3)
|
||||
# sheets
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。**
|
||||
|
||||
## 快速决策
|
||||
- 已知 spreadsheet URL / token 后,再进入 `sheets +info`、`sheets +read`、`sheets +find` 等对象内部操作。
|
||||
## 术语约定
|
||||
|
||||
## 核心概念
|
||||
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
|
||||
|
||||
### 文档类型与 Token
|
||||
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 工作表(sheet) | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
|
||||
| 电子表格(spreadsheet) | 工作簿、表格 | 顶层容器;由 `--url` 或 `--spreadsheet-token` 定位 |
|
||||
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image` 的 `--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
|
||||
|
||||
飞书开放平台中,不同类型的文档有不同的 URL 格式和 Token 处理方式。在进行文档操作(如添加评论、下载文件等)时,必须先获取正确的 `file_token`。
|
||||
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
|
||||
|
||||
### 文档 URL 格式与 Token 处理
|
||||
| 对象 | 主键 flag | 对象 | 主键 flag |
|
||||
| --- | --- | --- | --- |
|
||||
| 工作表 sheet | `--sheet-id` | 条件格式规则 | `--rule-id` |
|
||||
| 图表 chart | `--chart-id` | 筛选视图 | `--view-id` |
|
||||
| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` |
|
||||
| 浮动图片 | `--float-image-id` | | |
|
||||
|
||||
| URL 格式 | 示例 | Token 类型 | 处理方式 |
|
||||
|----------|---------------------------------------------------------|-----------|----------|
|
||||
| `/docx/` | `https://example.larksuite.com/docx/doxcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/doc/` | `https://example.larksuite.com/doc/doccnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/wiki/` | `https://example.larksuite.com/wiki/wikcnxxxxxxxxx` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |
|
||||
| `/sheets/` | `https://example.larksuite.com/sheets/shtcnxxxxxxxxx` | `file_token` | URL 路径中的 token 直接作为 `file_token` 使用 |
|
||||
| `/drive/folder/` | `https://example.larksuite.com/drive/folder/fldcnxxxx` | `folder_token` | URL 路径中的 token 作为文件夹 token 使用 |
|
||||
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
|
||||
|
||||
### Wiki 链接特殊处理(关键!)
|
||||
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag,先对照本表,避免一次必然失败的试错。完整 shortcut 见各工具参考。
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。**不能直接假设 URL 中的 token 就是 file_token**,必须先查询实际类型和真实 token。
|
||||
| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) |
|
||||
| --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | — |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — |
|
||||
| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` |
|
||||
| 查找并替换 | `+cells-replace` | — |
|
||||
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` |
|
||||
| 看工作簿 / 子表清单 | `+workbook-info` | — |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
|
||||
#### 处理流程
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
|
||||
|
||||
1. **使用 `wiki.spaces.get_node` 查询节点信息**
|
||||
```bash
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
```
|
||||
## References
|
||||
|
||||
2. **从返回结果中提取关键信息**
|
||||
- `node.obj_type`:文档类型(docx/doc/sheet/bitable/slides/file/mindnote)
|
||||
- `node.obj_token`:**真实的文档 token**(用于后续操作)
|
||||
- `node.title`:文档标题
|
||||
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut),它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。
|
||||
|
||||
3. **根据 `obj_type` 使用对应的 API**
|
||||
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut)
|
||||
|
||||
| obj_type | 说明 | 使用的 API |
|
||||
|----------|------|-----------|
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 | `bitable.*` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 |
|
||||
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 |
|
||||
|
||||
#### 查询示例
|
||||
### 按对象的工具参考(含 shortcut)
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 |
|
||||
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 |
|
||||
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。仅针对飞书表格。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。仅针对飞书表格。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 |
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如:
|
||||
|
||||
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」),加 `--dry-run`
|
||||
- `_公共:URL/token(无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token,常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
|
||||
|
||||
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
|
||||
|
||||
### 公共 flag(定位资源)
|
||||
|
||||
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"):
|
||||
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
|
||||
- **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。
|
||||
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。
|
||||
- ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:shell session 起手先 `set +H`** 关 bash history expansion,否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII)的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`。
|
||||
- **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet URL |
|
||||
| `--spreadsheet-token` | string | 二选一必填(与 `--url`) | spreadsheet token |
|
||||
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut) | 工作表 reference_id |
|
||||
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut) | 工作表名称 |
|
||||
|
||||
**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可):
|
||||
|
||||
```bash
|
||||
# 查询 wiki 节点
|
||||
lark-cli wiki spaces get_node --params '{"token":"wiki_token"}'
|
||||
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
|
||||
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
|
||||
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
|
||||
# 例:lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
|
||||
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
|
||||
```
|
||||
|
||||
返回结果示例:
|
||||
```json
|
||||
{
|
||||
"node": {
|
||||
"obj_type": "docx",
|
||||
"obj_token": "xxxx",
|
||||
"title": "标题",
|
||||
"node_type": "origin",
|
||||
"space_id": "12345678910"
|
||||
}
|
||||
}
|
||||
```
|
||||
### 系统 flag
|
||||
|
||||
### 资源关系
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 |
|
||||
| `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
|
||||
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。与 `--flag-name <name>` 搭配指定要查哪个 flag;省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag(如 `--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 |
|
||||
| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名(不带 `--` 前缀,如 `cells` / `properties` / `operations`)。 |
|
||||
|
||||
```
|
||||
Wiki Space (知识空间)
|
||||
└── Wiki Node (知识库节点)
|
||||
├── obj_type: docx (新版文档)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: doc (旧版文档)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: sheet (电子表格)
|
||||
│ └── obj_token (真实文档 token)
|
||||
├── obj_type: bitable (多维表格)
|
||||
│ └── obj_token (真实文档 token)
|
||||
└── obj_type: file/slides/mindnote
|
||||
└── obj_token (真实文档 token)
|
||||
**Agent 使用提示**:写复合 JSON flag(`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload,比靠 reference 的速查表更精确,也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema` 或 `## Examples` 的真实示例。
|
||||
|
||||
Drive Folder (云空间/云盘/云存储文件夹)
|
||||
└── File (文件/文档)
|
||||
└── file_token (直接使用)
|
||||
```
|
||||
### flag 内容类型与输出约定(术语速记)
|
||||
|
||||
**操作流程(重要):**
|
||||
- flag 表里 JSON 类入参标三类:**复合 JSON** = 深层嵌套对象(用 `--print-schema` 取完整结构);**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema);**非 JSON 文本** = 原样文本(如 CSV)。`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)。
|
||||
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get` 的 `annotated_csv`);写操作不会自动回读,如需校验请自行调用对应的 `+*-list` / `+*-get` / `+cells-get`。
|
||||
|
||||
1. **create** — 创建筛选
|
||||
- 用于首次创建筛选
|
||||
- ⚠️ range 必须覆盖所有需要筛选的列(如 B1:E200)
|
||||
- 如果已有筛选存在,再用 create 会覆盖整个筛选
|
||||
## 复合 JSON / 大入参:优先 stdin
|
||||
|
||||
2. **update** — 更新筛选
|
||||
- 用于在已有筛选上添加/更新指定列的条件
|
||||
- 只需指定 col 和 condition,不需要 range
|
||||
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin(`-`)传入,避免命令行超长与 shell 转义问题。
|
||||
|
||||
3. **delete** — 删除筛选
|
||||
|
||||
4. **get** — 获取筛选状态
|
||||
|
||||
**多列筛选示例:**
|
||||
|
||||
创建媒体名称(B列)和情感分析(E列)的双重筛选:
|
||||
推荐写法:payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
|
||||
|
||||
```bash
|
||||
# 1. 删除现有筛选(如有)
|
||||
lark-cli sheets spreadsheet.sheet.filters delete \
|
||||
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}'
|
||||
|
||||
# 2. 创建第一个筛选,range 覆盖所有要筛选的列
|
||||
lark-cli sheets spreadsheet.sheet.filters create \
|
||||
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}' \
|
||||
--data '{"col":"B","condition":{"expected":["xx"],"filter_type":"multiValue"},"range":"<sheet_id>!B1:E200"}'
|
||||
|
||||
# 3. 添加第二个筛选条件
|
||||
lark-cli sheets spreadsheet.sheet.filters update \
|
||||
--params '{"spreadsheet_token":"<spreadsheet_token>","sheet_id":"<sheet_id>"}' \
|
||||
--data '{"col":"E","condition":{"expected":["xx"],"filter_type":"multiValue"}}'
|
||||
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
|
||||
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
|
||||
```
|
||||
|
||||
**常见错误:**
|
||||
- `Wrong Filter Value`:筛选已存在,需要先 delete 再 create
|
||||
- `Excess Limit`:update 时重复添加同一列条件
|
||||
|
||||
### 单元格数据类型
|
||||
|
||||
接受二维数组的 shortcut(`+write`/`+append` 的 `--values`、`+create` 的 `--data`)中,每个单元格值支持以下类型。**公式、带文本链接、@人、@文档、下拉列表必须使用对象格式**,直接传字符串会被当作纯文本存储。
|
||||
|
||||
| 类型 | 写入格式 | 示例 |
|
||||
|------|---------|------|
|
||||
| 字符串 | `"文本"` | `"hello"` |
|
||||
| 数字 | `数字` | `123`、`3.14` |
|
||||
| 日期 | `数字`(自 1899-12-30 起的天数,需先设单元格日期格式) | `42101` |
|
||||
| 链接(纯 URL) | `"URL 字符串"` | `"https://example.com"` |
|
||||
| 链接(带文本) | `{"type":"url","text":"显示文本","link":"URL"}` | `{"type":"url","text":"飞书","link":"https://www.feishu.cn"}` |
|
||||
| 邮箱 | `"邮箱字符串"` | `"user@example.com"` |
|
||||
| **公式** | `{"type":"formula","text":"=公式"}` | `{"type":"formula","text":"=SUM(A1:A10)"}` |
|
||||
| @人 | `{"type":"mention","text":"标识","textType":"email\|openId\|unionId","notify":false}` | `{"type":"mention","text":"user@example.com","textType":"email","notify":false}`(notify 可选,默认 false;仅在用户明确要求通知时设为 true) |
|
||||
| @文档 | `{"type":"mention","textType":"fileToken","text":"token","objType":"类型"}` | `{"type":"mention","textType":"fileToken","text":"shtXXX","objType":"sheet"}` |
|
||||
| 下拉列表 | `{"type":"multipleValue","values":[值1,值2]}` | `{"type":"multipleValue","values":["选项A","选项B"]}` |
|
||||
|
||||
**写入公式示例**:
|
||||
|
||||
```bash
|
||||
# ✅ 正确:使用对象格式
|
||||
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[[{"type":"formula","text":"=SUM(C2:C5)"}]]'
|
||||
|
||||
# ❌ 错误:直接传字符串,会被存为纯文本
|
||||
lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
|
||||
--values '[["=SUM(C2:C5)"]]'
|
||||
```
|
||||
|
||||
> **公式语法参考**:涉及 ARRAYFORMULA、原生数组函数、MAP/LAMBDA、日期差、Excel 公式改写等飞书特有规则时,先阅读 [`references/lark-sheets-formula.md`](references/lark-sheets-formula.md)。
|
||||
|
||||
**限制**:
|
||||
- 公式支持 IMPORTRANGE 跨表引用(最多 5 层嵌套、每个工作表最多 100 个引用)
|
||||
- @人仅支持同租户用户,单次最多 50 人
|
||||
- 下拉列表需**先配置下拉选项**,否则 `multipleValue` 写入会变成纯文本。配置方法见 [`references/lark-sheets-dropdown.md#set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown)。值中的字符串不能包含逗号
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
|
||||
|
||||
### Spreadsheet Management
|
||||
|
||||
对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-sheets-spreadsheet-management.md#create) | Create a spreadsheet (optional header row and initial data) |
|
||||
| [`+info`](references/lark-sheets-spreadsheet-management.md#info) | View spreadsheet metadata and sheet information |
|
||||
| [`+export`](references/lark-sheets-spreadsheet-management.md#export) | Export a spreadsheet (async task polling + optional download) |
|
||||
|
||||
### Sheet Management
|
||||
|
||||
对应参考文档:[sheet-management](references/lark-sheets-sheet-management.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create-sheet`](references/lark-sheets-sheet-management.md#create-sheet) | Create a sheet in an existing spreadsheet |
|
||||
| [`+copy-sheet`](references/lark-sheets-sheet-management.md#copy-sheet) | Copy a sheet within a spreadsheet |
|
||||
| [`+delete-sheet`](references/lark-sheets-sheet-management.md#delete-sheet) | Delete a sheet from a spreadsheet |
|
||||
| [`+update-sheet`](references/lark-sheets-sheet-management.md#update-sheet) | Update sheet title, position, visibility, freeze, or protection |
|
||||
|
||||
### Cell Data
|
||||
|
||||
对应参考文档:[cell-data](references/lark-sheets-cell-data.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+read`](references/lark-sheets-cell-data.md#read) | Read spreadsheet cell values |
|
||||
| [`+write`](references/lark-sheets-cell-data.md#write) | Write to spreadsheet cells (overwrite mode) |
|
||||
| [`+append`](references/lark-sheets-cell-data.md#append) | Append rows to a spreadsheet |
|
||||
| [`+find`](references/lark-sheets-cell-data.md#find) | Find cells in a spreadsheet |
|
||||
| [`+replace`](references/lark-sheets-cell-data.md#replace) | Find and replace cell values |
|
||||
|
||||
### Cell Style And Merge
|
||||
|
||||
对应参考文档:[cell-style-and-merge](references/lark-sheets-cell-style-and-merge.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+set-style`](references/lark-sheets-cell-style-and-merge.md#set-style) | Set cell style for a range |
|
||||
| [`+batch-set-style`](references/lark-sheets-cell-style-and-merge.md#batch-set-style) | Batch set cell styles for multiple ranges |
|
||||
| [`+merge-cells`](references/lark-sheets-cell-style-and-merge.md#merge-cells) | Merge cells in a spreadsheet |
|
||||
| [`+unmerge-cells`](references/lark-sheets-cell-style-and-merge.md#unmerge-cells) | Unmerge (split) cells in a spreadsheet |
|
||||
|
||||
### Cell Images
|
||||
|
||||
对应参考文档:[cell-images](references/lark-sheets-cell-images.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+write-image`](references/lark-sheets-cell-images.md#write-image) | Write an image into a spreadsheet cell |
|
||||
|
||||
### Row Column Management
|
||||
|
||||
对应参考文档:[row-column-management](references/lark-sheets-row-column-management.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+add-dimension`](references/lark-sheets-row-column-management.md#add-dimension) | Add rows or columns at the end of a sheet |
|
||||
| [`+insert-dimension`](references/lark-sheets-row-column-management.md#insert-dimension) | Insert rows or columns at a specified position |
|
||||
| [`+update-dimension`](references/lark-sheets-row-column-management.md#update-dimension) | Update row or column properties (visibility, size) |
|
||||
| [`+move-dimension`](references/lark-sheets-row-column-management.md#move-dimension) | Move rows or columns to a new position |
|
||||
| [`+delete-dimension`](references/lark-sheets-row-column-management.md#delete-dimension) | Delete rows or columns |
|
||||
|
||||
### Filter Views
|
||||
|
||||
对应参考文档:[filter-views](references/lark-sheets-filter-views.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create-filter-view`](references/lark-sheets-filter-views.md#create-filter-view) | Create a filter view |
|
||||
| [`+update-filter-view`](references/lark-sheets-filter-views.md#update-filter-view) | Update a filter view |
|
||||
| [`+list-filter-views`](references/lark-sheets-filter-views.md#list-filter-views) | List all filter views in a sheet |
|
||||
| [`+get-filter-view`](references/lark-sheets-filter-views.md#get-filter-view) | Get a filter view by ID |
|
||||
| [`+delete-filter-view`](references/lark-sheets-filter-views.md#delete-filter-view) | Delete a filter view |
|
||||
| [`+create-filter-view-condition`](references/lark-sheets-filter-views.md#create-filter-view-condition) | Create a filter condition on a filter view |
|
||||
| [`+update-filter-view-condition`](references/lark-sheets-filter-views.md#update-filter-view-condition) | Update a filter condition |
|
||||
| [`+list-filter-view-conditions`](references/lark-sheets-filter-views.md#list-filter-view-conditions) | List all filter conditions of a filter view |
|
||||
| [`+get-filter-view-condition`](references/lark-sheets-filter-views.md#get-filter-view-condition) | Get a filter condition by column |
|
||||
| [`+delete-filter-view-condition`](references/lark-sheets-filter-views.md#delete-filter-view-condition) | Delete a filter condition |
|
||||
|
||||
### Dropdown
|
||||
|
||||
对应参考文档:[dropdown](references/lark-sheets-dropdown.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+set-dropdown`](references/lark-sheets-dropdown.md#set-dropdown) | 设置下拉列表(`multipleValue` 写入的前置步骤) |
|
||||
| [`+update-dropdown`](references/lark-sheets-dropdown.md#update-dropdown) | 更新下拉列表选项 |
|
||||
| [`+get-dropdown`](references/lark-sheets-dropdown.md#get-dropdown) | 查询下拉列表配置 |
|
||||
| [`+delete-dropdown`](references/lark-sheets-dropdown.md#delete-dropdown) | 删除下拉列表 |
|
||||
|
||||
### Float Images
|
||||
|
||||
对应参考文档:[float-images](references/lark-sheets-float-images.md)
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+media-upload`](references/lark-sheets-float-images.md#media-upload) | 上传本地图片素材,返回 `file_token`(供 `+create-float-image` 使用;>20MB 自动分片) |
|
||||
| [`+create-float-image`](references/lark-sheets-float-images.md#create-float-image) | 创建浮动图片 |
|
||||
| [`+update-float-image`](references/lark-sheets-float-images.md#update-float-image) | 更新浮动图片属性 |
|
||||
| [`+get-float-image`](references/lark-sheets-float-images.md#get-float-image) | 获取浮动图片 |
|
||||
| [`+list-float-images`](references/lark-sheets-float-images.md#list-float-images) | 查询所有浮动图片 |
|
||||
| [`+delete-float-image`](references/lark-sheets-float-images.md#delete-float-image) | 删除浮动图片 |
|
||||
|
||||
### Formula
|
||||
|
||||
对应参考文档:[formula](references/lark-sheets-formula.md)
|
||||
|
||||
> 浮动图片相关的读接口只返回元数据(含 `float_image_token`),**不包含图片字节**。要读取图片内容,用 token 调 `lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png`。
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema sheets.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli sheets <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
|
||||
### spreadsheets
|
||||
|
||||
- `create` — 创建电子表格
|
||||
- `get` — 获取电子表格信息
|
||||
- `patch` — 修改电子表格属性
|
||||
|
||||
### spreadsheet.sheet.filters
|
||||
|
||||
- `create` — 创建筛选
|
||||
- `delete` — 删除筛选
|
||||
- `get` — 获取筛选
|
||||
- `update` — 更新筛选
|
||||
|
||||
### spreadsheet.sheets
|
||||
|
||||
- `find` — 查找单元格
|
||||
|
||||
### spreadsheet.sheet.float_images
|
||||
|
||||
- `create` — 创建浮动图片
|
||||
- `patch` — 更新浮动图片
|
||||
- `get` — 获取浮动图片
|
||||
- `query` — 查询所有浮动图片
|
||||
- `delete` — 删除浮动图片
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|------|-----------|
|
||||
| `spreadsheets.create` | `sheets:spreadsheet:create` |
|
||||
| `spreadsheets.get` | `sheets:spreadsheet.meta:read` |
|
||||
| `spreadsheets.patch` | `sheets:spreadsheet.meta:write_only` |
|
||||
| `spreadsheet.sheet.filters.create` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheet.filters.delete` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheet.filters.get` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.filters.update` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheets.find` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.float_images.create` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheet.float_images.patch` | `sheets:spreadsheet:write_only` |
|
||||
| `spreadsheet.sheet.float_images.get` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.float_images.query` | `sheets:spreadsheet:read` |
|
||||
| `spreadsheet.sheet.float_images.delete` | `sheets:spreadsheet:write_only` |
|
||||
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`--<flag> - < 文件`)。
|
||||
|
||||
191
skills/lark-sheets/references/lark-sheets-batch-update.md
Normal file
191
skills/lark-sheets/references/lark-sheets-batch-update.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Lark Sheet Batch Update
|
||||
|
||||
## 写入边界 + 回读校验
|
||||
|
||||
`+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束:
|
||||
|
||||
1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。
|
||||
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get` 或 `+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照。
|
||||
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。批量执行多个写入工具操作。将多个工具调用合并为一次请求,按顺序依次执行。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。注意:不支持嵌套 `+batch-update`。
|
||||
|
||||
**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+dropdown-update` / `+dropdown-delete` / `+cells-batch-set-style` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
|
||||
|
||||
**⚠️ 何时必须使用 `+batch-update`(硬性要求)**:
|
||||
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
|
||||
- 需要对**多个**不同区域执行 `+rows-resize / +cols-resize` 时(如统一调整多列列宽或多行行高)
|
||||
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`)
|
||||
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
|
||||
|
||||
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
|
||||
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+batch-update` | high-risk-write | 批量 |
|
||||
| `+cells-batch-set-style` | write | 批量 |
|
||||
| `+dropdown-update` | write | 对象 |
|
||||
| `+dropdown-delete` | high-risk-write | 对象 |
|
||||
| `+cells-batch-clear` | high-risk-write | 批量 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+batch-update`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认严格事务(首个失败即整批中断),传 --continue-on-error 切换为软批量(遇失败仍继续);不支持嵌套;按数组顺序串行执行 |
|
||||
| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) |
|
||||
|
||||
### `+cells-batch-set-style`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;所有 range 应用同一组 style |
|
||||
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) |
|
||||
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) |
|
||||
| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) |
|
||||
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) |
|
||||
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) |
|
||||
| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) |
|
||||
| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) |
|
||||
| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) |
|
||||
| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) |
|
||||
| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) |
|
||||
| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON(结构同 +cells-set-style) |
|
||||
|
||||
### `+dropdown-update`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
|
||||
| `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
|
||||
| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**;`--highlight=false` 时被忽略。 |
|
||||
| `--multiple` | bool | optional | 启用多选 |
|
||||
| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 |
|
||||
| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会向 stderr 输出 warning。如需取消,传 `--highlight=false`。 |
|
||||
|
||||
### `+dropdown-delete`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["'Sheet1'!E2:E6"]`),每项必须带 sheet 前缀;前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
|
||||
|
||||
### `+cells-batch-clear`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须是 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 |
|
||||
| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+batch-update` `--operations`
|
||||
|
||||
_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
|
||||
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 …
|
||||
|
||||
### `+cells-batch-set-style` `--border-styles`
|
||||
|
||||
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_
|
||||
|
||||
**顶层字段**:
|
||||
- `top` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `bottom` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `left` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
- `right` (object?) { style?: enum, weight?: enum, color?: string }
|
||||
|
||||
### `+dropdown-update` `--options`
|
||||
|
||||
_列表选项_
|
||||
|
||||
**数组项**(类型 string):
|
||||
- 标量:string
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR;`+batch-update` 本身不强制 sheet-id,子操作各自携带)。
|
||||
|
||||
### `+batch-update`
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \
|
||||
--operations @ops.json
|
||||
|
||||
# ops.json (array<{shortcut, input}>,shortcut 用 CLI 名):
|
||||
# [
|
||||
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}},
|
||||
# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}}
|
||||
# ]
|
||||
```
|
||||
|
||||
> ⚠️ **子操作定位规则**:
|
||||
> - spreadsheet 定位(`--url` / `--spreadsheet-token`)**只在顶层给一次**;`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。
|
||||
> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item)。
|
||||
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"dimension":"row"`),不要把整组 `--operations` 再套一层嵌套 JSON。
|
||||
|
||||
> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
|
||||
>
|
||||
> ```jsonc
|
||||
> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行
|
||||
> [
|
||||
> {"shortcut": "+dim-insert",
|
||||
> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}},
|
||||
> {"shortcut": "+cells-set",
|
||||
> "input": {"sheet_id": "...", "range": "C1:C100",
|
||||
> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}}
|
||||
> ]
|
||||
> ```
|
||||
|
||||
### `+cells-batch-set-style`
|
||||
|
||||
多 range 应用同一组 style(服务端走 `+batch-update` 原子事务):
|
||||
|
||||
```bash
|
||||
# 表头行 + 汇总行同时刷成蓝底白字
|
||||
lark-cli sheets +cells-batch-set-style --url "..." \
|
||||
--ranges '["sheet1!A1:F1","sheet1!A30:F30"]' \
|
||||
--background-color "#1E5BC6" --font-color "#FFFFFF" --font-weight bold
|
||||
```
|
||||
|
||||
### `+cells-batch-clear`
|
||||
|
||||
多 range 一次性清除(服务端走 `+batch-update` 原子事务);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`:
|
||||
|
||||
```bash
|
||||
# dry-run 先看清除范围
|
||||
lark-cli sheets +cells-batch-clear --url "..." \
|
||||
--ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --dry-run
|
||||
# 执行
|
||||
lark-cli sheets +cells-batch-clear --url "..." \
|
||||
--ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --yes
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验;**禁止嵌套 `+batch-update`**。`+cells-batch-set-style` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀;样式 flag 至少一个非空(或带 `--border-styles`)。`+cells-batch-clear` 的 `--ranges` 同样必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。
|
||||
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板;首个失败则整批 fail-fast(不实际执行任何后续)。
|
||||
- `Execute`:按声明顺序串行执行;任一子操作失败立即中断并回滚到该子操作前状态(具体回滚能力取决于子操作类型,沿用 `+batch-update` 的语义)。
|
||||
@@ -1,197 +0,0 @@
|
||||
# Sheets Cell Data
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总单元格数据操作:
|
||||
|
||||
- `+read`
|
||||
- `+write`
|
||||
- `+append`
|
||||
- `+find`
|
||||
- `+replace`
|
||||
|
||||
<a id="read"></a>
|
||||
## `+read`
|
||||
|
||||
对应命令:`lark-cli sheets +read`
|
||||
|
||||
内置能力:
|
||||
|
||||
- 支持 `--url` / `--spreadsheet-token` 二选一(URL 支持 wiki)
|
||||
- 若已传 `--sheet-id`,`--range` 可写 `A1:D10` 或 `C2`
|
||||
- 默认最多返回 200 行
|
||||
|
||||
```bash
|
||||
lark-cli sheets +read --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:H20"
|
||||
|
||||
lark-cli sheets +read --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "C2"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 否 | `<sheetId>!A1:D10`、`A1:D10` / `C2` 或 `<sheetId>` |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--value-render-option` | 否 | `ToString` / `FormattedValue` / `Formula` / `UnformattedValue` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `range`
|
||||
- `values`
|
||||
- `truncated`
|
||||
- `total_rows`
|
||||
|
||||
<a id="write"></a>
|
||||
## `+write`
|
||||
|
||||
对应命令:`lark-cli sheets +write`
|
||||
|
||||
用于覆盖写入一个矩形区域。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +write --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:B2" \
|
||||
--values '[["name","age"],["alice",18]]'
|
||||
|
||||
lark-cli sheets +write --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "C2" \
|
||||
--values '[["hello"]]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 否 | 写入范围;可用相对范围或 `<sheetId>` |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--values` | 是 | 二维数组 JSON |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `updated_range`
|
||||
- `updated_rows`
|
||||
- `updated_columns`
|
||||
- `updated_cells`
|
||||
- `revision`
|
||||
|
||||
<a id="append"></a>
|
||||
## `+append`
|
||||
|
||||
对应命令:`lark-cli sheets +append`
|
||||
|
||||
用于向工作表末尾追加行。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +append --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1" \
|
||||
--values '[["华东一仓","2026-03",125000,98000,168000,"41.7%"]]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 否 | 追加范围:支持 `<sheetId>`、完整范围、相对范围 |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--values` | 是 | 二维数组 JSON |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `table_range`
|
||||
- `updated_range`
|
||||
- `updated_rows`
|
||||
- `updated_columns`
|
||||
- `updated_cells`
|
||||
- `revision`
|
||||
|
||||
<a id="find"></a>
|
||||
## `+find`
|
||||
|
||||
对应命令:`lark-cli sheets +find`
|
||||
|
||||
只在一个已知 spreadsheet 内查找单元格内容,不是云空间(云盘/云存储)搜索。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +find --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --find "张三" --range "A1:H200"
|
||||
|
||||
lark-cli sheets +find --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --find "仓库管理营收报表" --ignore-case
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--find` | 是 | 查找内容 |
|
||||
| `--range` | 否 | 范围;不填则搜索整个工作表 |
|
||||
| `--ignore-case` | 否 | 不区分大小写 |
|
||||
| `--match-entire-cell` | 否 | 完全匹配单元格 |
|
||||
| `--search-by-regex` | 否 | 使用正则 |
|
||||
| `--include-formulas` | 否 | 搜索公式 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `matched_cells`
|
||||
- `matched_formula_cells`
|
||||
- `rows_count`
|
||||
|
||||
<a id="replace"></a>
|
||||
## `+replace`
|
||||
|
||||
对应命令:`lark-cli sheets +replace`
|
||||
|
||||
在指定范围内查找并替换单元格内容。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --find "hello" --replacement "world"
|
||||
|
||||
lark-cli sheets +replace --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --find "\\d{4}-\\d{2}-\\d{2}" \
|
||||
--replacement "DATE" --search-by-regex
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--find` | 是 | 搜索文本 |
|
||||
| `--replacement` | 是 | 替换文本 |
|
||||
| `--range` | 否 | 搜索范围,不传则搜索整个工作表 |
|
||||
| `--match-case` | 否 | 区分大小写 |
|
||||
| `--match-entire-cell` | 否 | 匹配整个单元格 |
|
||||
| `--search-by-regex` | 否 | 使用正则 |
|
||||
| `--include-formulas` | 否 | 在公式中搜索 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `replace_result.matched_cells`
|
||||
- `replace_result.matched_formula_cells`
|
||||
- `replace_result.rows_count`
|
||||
|
||||
## 参考
|
||||
|
||||
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id`
|
||||
- [dropdown](lark-sheets-dropdown.md#set-dropdown) — 写入 `multipleValue` 前先设置下拉列表
|
||||
- [formula](lark-sheets-formula.md) — 公式写入规则
|
||||
@@ -1,59 +0,0 @@
|
||||
# Sheets Cell Images
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总单元格图片写入能力:
|
||||
|
||||
- `+write-image`
|
||||
|
||||
<a id="write-image"></a>
|
||||
## `+write-image`
|
||||
|
||||
对应命令:`lark-cli sheets +write-image`
|
||||
|
||||
特性:
|
||||
|
||||
- 将本地图片文件写入到指定单元格
|
||||
- 支持格式:PNG、JPEG、JPG、GIF、BMP、JFIF、EXIF、TIFF、BPG、HEIC
|
||||
- `--range` 必须表示单个单元格,如 `A1` 或 `<sheetId>!B2:B2`
|
||||
- `--name` 默认取 `--image` 的文件名
|
||||
|
||||
```bash
|
||||
# 写入图片到指定单元格
|
||||
lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!B2:B2" \
|
||||
--image "./logo.png"
|
||||
|
||||
# 使用 URL + sheet-id,指定单个单元格
|
||||
lark-cli sheets +write-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "C3" \
|
||||
--image "./chart.jpg"
|
||||
|
||||
# 自定义图片名称
|
||||
lark-cli sheets +write-image --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:A1" \
|
||||
--image "./output.png" --name "revenue_chart.png"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 目标单元格:`<sheetId>!A1:A1` 或相对单元格 |
|
||||
| `--sheet-id` | 否 | 工作表 ID |
|
||||
| `--image` | 是 | 本地图片文件的相对路径 |
|
||||
| `--name` | 否 | 图片文件名(默认取 `--image` 的文件名) |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `spreadsheetToken`
|
||||
- `updateRange`
|
||||
- `revision`
|
||||
|
||||
## 参考
|
||||
|
||||
- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据
|
||||
- [float-images](lark-sheets-float-images.md) — 管理浮动图片
|
||||
@@ -1,141 +0,0 @@
|
||||
# Sheets Cell Style and Merge
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总单元格样式和合并相关操作:
|
||||
|
||||
- `+set-style`
|
||||
- `+batch-set-style`
|
||||
- `+merge-cells`
|
||||
- `+unmerge-cells`
|
||||
|
||||
<a id="set-style"></a>
|
||||
## `+set-style`
|
||||
|
||||
对应命令:`lark-cli sheets +set-style`
|
||||
|
||||
对指定范围设置字体、颜色、对齐、边框等样式。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:C3" \
|
||||
--style '{"font":{"bold":true},"backColor":"#ff0000"}'
|
||||
|
||||
lark-cli sheets +set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:Z100" --style '{"clean":true}'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 单元格范围 |
|
||||
| `--sheet-id` | 否 | 工作表 ID(用于相对范围) |
|
||||
| `--style` | 是 | 样式 JSON 对象 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
常用 `style` 字段:
|
||||
|
||||
- `font.bold`
|
||||
- `font.italic`
|
||||
- `font.font_size`
|
||||
- `textDecoration`
|
||||
- `formatter`
|
||||
- `hAlign`
|
||||
- `vAlign`
|
||||
- `foreColor`
|
||||
- `backColor`
|
||||
- `borderType`
|
||||
- `borderColor`
|
||||
- `clean`
|
||||
|
||||
输出:`updates`(updatedRange / updatedRows / updatedColumns / updatedCells / revision)
|
||||
|
||||
<a id="batch-set-style"></a>
|
||||
## `+batch-set-style`
|
||||
|
||||
对应命令:`lark-cli sheets +batch-set-style`
|
||||
|
||||
对多个范围批量设置不同样式。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +batch-set-style --spreadsheet-token "shtxxxxxxxx" \
|
||||
--data '[{"ranges":["<sheetId>!A1:C3"],"style":{"font":{"bold":true},"backColor":"#21d11f"}},{"ranges":["<sheetId>!D1:F3"],"style":{"foreColor":"#ff0000"}}]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--data` | 是 | JSON 数组,每项包含 `ranges` 和 `style` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `totalUpdatedRows`
|
||||
- `totalUpdatedColumns`
|
||||
- `totalUpdatedCells`
|
||||
- `revision`
|
||||
- `responses[]`
|
||||
|
||||
<a id="merge-cells"></a>
|
||||
## `+merge-cells`
|
||||
|
||||
对应命令:`lark-cli sheets +merge-cells`
|
||||
|
||||
支持三种模式:
|
||||
|
||||
- `MERGE_ALL`
|
||||
- `MERGE_ROWS`
|
||||
- `MERGE_COLUMNS`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +merge-cells --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:B2" --merge-type MERGE_ALL
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 单元格范围 |
|
||||
| `--sheet-id` | 否 | 工作表 ID(用于相对范围) |
|
||||
| `--merge-type` | 是 | `MERGE_ALL` / `MERGE_ROWS` / `MERGE_COLUMNS` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`spreadsheetToken`
|
||||
|
||||
<a id="unmerge-cells"></a>
|
||||
## `+unmerge-cells`
|
||||
|
||||
对应命令:`lark-cli sheets +unmerge-cells`
|
||||
|
||||
用于拆分合并单元格。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +unmerge-cells --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A1:B2"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 单元格范围 |
|
||||
| `--sheet-id` | 否 | 工作表 ID(用于相对范围) |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`spreadsheetToken`
|
||||
|
||||
## 参考
|
||||
|
||||
- [cell-data](lark-sheets-cell-data.md) — 数据读写
|
||||
- [cell-images](lark-sheets-cell-images.md) — 写入单元格图片
|
||||
319
skills/lark-sheets/references/lark-sheets-chart.md
Normal file
319
skills/lark-sheets/references/lark-sheets-chart.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Lark Sheet Chart
|
||||
|
||||
## 真对象硬约束
|
||||
|
||||
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读写图表对象。本 reference 覆盖 4 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 |
|
||||
| 创建/更新/删除图表 | `+chart-{create|update|delete}` | 对图表对象执行写入操作 |
|
||||
|
||||
典型工作流:先读取现有图表了解配置 → 执行创建/更新/删除 → 再次读取验证结果。
|
||||
|
||||
## 需求→图表类型映射(创建前必查)
|
||||
|
||||
| 用户说 | 图表类型 | 备注 |
|
||||
|--------|---------|------|
|
||||
| "占比"、"比例"、"各XX占多少" | 饼图(pie) | 单维度占比首选 |
|
||||
| "对比"、"各XX的YY" | 柱形图(column,纵向) | 多类别数值对比;横向条形用 `bar` |
|
||||
| "趋势"、"变化"、"走势" | 折线图(line) | 时间序列首选 |
|
||||
| "堆积"、"组成构成" | 堆积柱形图(column + stack) | 多系列累加 |
|
||||
| "分布"、"相关性" | 散点图(scatter) | 两变量关系 |
|
||||
|
||||
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
|
||||
|
||||
**`--properties` 结构锚点(构造前必读)**:`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
|
||||
- **数据标签缺失**:用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段
|
||||
- **数据源范围与系列名来源要对齐**:
|
||||
- **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
|
||||
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
|
||||
- **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。
|
||||
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。
|
||||
- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确
|
||||
|
||||
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。**
|
||||
> 例如用户说"横轴为车型系列,纵轴为Q1-Q4的销量",你不能猜测列索引,必须先通过读取表格数据源范围的首行内容(使用 `lark-sheets-read-data` 的 `+cells-get` 或其他读取单元格的工具),确认"车型系列"是第几列、"Q1"~"Q4"分别是第几列,然后再将正确的列索引填入 `dim1.serie.index` 和 `dim2.series[].index`。
|
||||
|
||||
> **⚠️ 硬性规则:数据与表头分离场景必须使用 detached 模式。** 当 `refs` 仅覆盖数据的一个子集,而真正的语义表头行/列位于该子集之外时,**必须** `snapshot.data.headerMode='detached'` 并配上 `nameRef`。不能用 inline 模式 + 把 refs 多带 1 行兜底表头来替代——那种写法已废弃。否则图表会把错误的首行/首列当系列名,或图例显示成"系列1/系列2"等默认名,或者 refs 里混入相邻分组的数据。
|
||||
>
|
||||
> **触发该规则的典型信号**(满足任意一条都必须走 detached):
|
||||
> - 用户要求"针对 X 类的数据画图"、"只看某个分组"、"只画筛选后的部分",而 X 类对应的行段在数据中间或末尾,与表头不连续;
|
||||
> - 用户要求"按 X 分别画图"、"按某个维度(部门/品类/地区/时间段等)拆图"——**多张图共享同一组表头**;
|
||||
> - `refs` 起始行 > 表头行(如表头在第 1 行,但 `refs` 从第 11 行开始);
|
||||
> - `refs` 起始列 > 表头列(如表头在 A 列,但 `refs` 从 C 列开始)。
|
||||
>
|
||||
> **正确做法**:
|
||||
> 1. 在 `data` 下显式设置 `"headerMode": "detached"`;
|
||||
> 2. `refs` **只覆盖该子集的纯数据**,不要向上/向左多带 1 行/列,也不要把全局表头整段并进来(否则会把其它分组的数据混进图);
|
||||
> 3. **`nameRef` 必填**:给 `dim1.serie.nameRef` 写真正表头中"类别名"那一格的 A1 引用(如 `'Sheet2'!A1`,sheet 名按 A1 标准单引号包裹),给每个 `dim2.series[i].nameRef` 写对应数值列的 A1 引用(如 `'Sheet2'!C1`、`'Sheet2'!D1`)。任一缺失会被校验拦下并报 `headerMode=detached requires ... nameRef`;
|
||||
> 4. `refs[i].value` 必须是单元格或普通矩形范围(CELL / NORMAL),不接受整行/整列/开区间;`direction='column'` 时起始行必须 > 0,`direction='row'` 时起始列必须 > 0;
|
||||
> 5. `index` 仍按 `refs` 内的列/行号填,从 1 开始。
|
||||
>
|
||||
> **两种场景对照(互斥,二选一)**:
|
||||
>
|
||||
> | 场景 | 何时命中 | 写法 |
|
||||
> |---|---|---|
|
||||
> | A. 表头与数据连在一起 | 单张图、refs 首行/首列就是表头(典型整段画图) | **省略 headerMode**(默认 inline),refs 含表头,**不写 nameRef** |
|
||||
> | B. 表头与数据分离 | 上面 4 条信号任一命中(数据子集、按维度拆图等) | **`headerMode='detached'`**,refs 仅纯数据,**`nameRef` 必填** |
|
||||
>
|
||||
> **反向约束**:场景 A 下不要写 `nameRef`——首行命名已经生效,多写反而冗余。`nameRef` 仅在场景 B 下使用(且必填)。
|
||||
|
||||
## ⚠️ chart 数据源引用 pivot 时必须排除总计行(高频致命错误)
|
||||
|
||||
当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去:
|
||||
- **柱形图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见
|
||||
- **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真
|
||||
|
||||
**正确流程**:
|
||||
1. `+pivot-create create` 返回 `sheet_id` + `pivot_table_id`
|
||||
2. 调 `+csv-get(sheet_id, 'A1:E30')` 或 `+pivot-list` 读 pivot 产物的**实际数据范围**
|
||||
3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计)
|
||||
4. `+chart-create create` 时 `snapshot.data.refs` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`)
|
||||
|
||||
## 图表位置选择(创建前必做)
|
||||
|
||||
凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`)。按以下四步走:
|
||||
|
||||
1. **查尺寸**:`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount;`+sheet-info` 只返回布局,不含行列总数)。
|
||||
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。
|
||||
3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
|
||||
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
|
||||
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`;
|
||||
- 否则先调 `+dim-insert`(`lark-sheets-sheet-structure`)扩行/列,再 create。
|
||||
|
||||
**示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
|
||||
- ❌ `{row: 0, col: "W"}` — col=22 越界
|
||||
- ✅ `{row: 42, col: "A"}` — 放数据下方
|
||||
- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U 列后插 6 列;U=index 20,after 即从 21 起),再放图到 `{row: 0, col: "V"}`
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+chart-list` | read | 对象 |
|
||||
| `+chart-create` | write | 对象 |
|
||||
| `+chart-update` | write | 对象 |
|
||||
| `+chart-delete` | high-risk-write | 对象 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+chart-list`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 |
|
||||
|
||||
### `+chart-create`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
|
||||
|
||||
### `+chart-update`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--chart-id` | string | required | 目标图表 reference_id |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch) |
|
||||
|
||||
### `+chart-delete`
|
||||
|
||||
_公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--chart-id` | string | required | 目标图表 reference_id |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+chart-create` `--properties` / `+chart-update` `--properties`
|
||||
|
||||
_创建/更新的图表属性_
|
||||
|
||||
**顶层字段**:
|
||||
- `position` (object) — 必填 { row: number, col: string }
|
||||
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
|
||||
- `size` (object) — 必填 { width: number, height: number }
|
||||
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则同 `+csv-get`)。
|
||||
|
||||
### `+chart-list`
|
||||
|
||||
输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。
|
||||
|
||||
### `+chart-create`
|
||||
|
||||
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**(1-based,对应 `refs.value` 范围内的列序)。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
|
||||
|
||||
最小可用列图(inline 模式:refs 含表头行):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--sheet-name "Sheet1" --properties '{
|
||||
"position":{"row":42,"col":"A"},
|
||||
"size":{"width":600,"height":400},
|
||||
"snapshot":{
|
||||
"data":{
|
||||
"refs":[{"value":"'Sheet1'!A1:B10"}],
|
||||
"dim1":{"serie":{"index":1}},
|
||||
"dim2":{"series":[{"index":2}]}
|
||||
},
|
||||
"plotArea":{"plot":{"type":"column"}}
|
||||
}
|
||||
}'
|
||||
|
||||
# 走文件(推荐配置较多时)
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json
|
||||
```
|
||||
|
||||
**饼图专属示例**(`sectors` 必须嵌在 `plotArea.plot.series[i].sectors.sector[]`,且 `sector[].index` 1-based):
|
||||
|
||||
饼图比 column / bar 更复杂:`sectors` 是 object,里面再包一个**单数** `sector` 数组——CLI 不替你 normalize,写错路径会被 server schema 直接拒。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
"position":{"row":24,"col":"F"},
|
||||
"size":{"width":600,"height":450},
|
||||
"snapshot":{
|
||||
"title":{"text":"各部门员工人数占比"},
|
||||
"plotArea":{"plot":{
|
||||
"type":"pie",
|
||||
"series":[{
|
||||
"index":1,
|
||||
"sectors":{"sector":[{"index":1,"offsetRadius":0.05}]}
|
||||
}]
|
||||
}},
|
||||
"data":{
|
||||
"refs":[{"value":"'Sheet1'!A1:B11"}],
|
||||
"dim1":{"serie":{"index":1,"aggregate":true}},
|
||||
"dim2":{"series":[{"index":2,"aggregateType":"sum"}]}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**数据与表头分离(必须用 `detached` + `nameRef`)**:
|
||||
|
||||
场景:周度销量明细表,真实表头在第 1 行(A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 11–17 行)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
|
||||
"position":{"row":7,"col":"F"},
|
||||
"size":{"width":600,"height":360},
|
||||
"snapshot":{
|
||||
"title":{"text":"3 号店周度订单/退款"},
|
||||
"plotArea":{"plot":{"type":"column"}},
|
||||
"data":{
|
||||
"headerMode":"detached",
|
||||
"direction":"column",
|
||||
"refs":[{"value":"'Sheet2'!A11:D17"}],
|
||||
"dim1":{"serie":{"index":1,"nameRef":"'Sheet2'!A1"}},
|
||||
"dim2":{"series":[
|
||||
{"index":3,"nameRef":"'Sheet2'!C1"},
|
||||
{"index":4,"nameRef":"'Sheet2'!D1"}
|
||||
]}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
约束:
|
||||
- `refs` 只覆盖纯数据 `A11:D17`,**不要**把表头行 A1 并进来
|
||||
- `nameRef` 在 detached 模式下**必填**,缺了被校验报 `headerMode=detached requires ... nameRef`
|
||||
- `index` 按 refs 内的列序算(A=1、B=2、C=3、D=4),**不是**全表列号
|
||||
- `nameRef` 必须配对应的 `index`;单写 `nameRef` 不传 `index` 直接报参数错
|
||||
|
||||
**多张图共享同一组表头(按维度拆图,必须用 detached)**:
|
||||
|
||||
场景:销售明细表头在 A1:E1(月份/区域/销售额/订单数/客单价),数据按区域分 3 段(华北 A2:E9、华东 A10:E17、华南 A18:E25),要分别画 3 张图。
|
||||
|
||||
❌ 常见错误:
|
||||
|
||||
```jsonc
|
||||
// 错误 1:refs 含全局表头但跨段 —— 多个区域被混进同一张图
|
||||
{"data":{"refs":[{"value":"'Sheet'!A1:E17"}], ... }} // 华东图混进华北 8 行
|
||||
// 错误 2:inline + refs 只取数据段、不写 detached/nameRef —— 图例显示成具体数据值
|
||||
{"data":{"refs":[{"value":"'Sheet'!A10:E17"}],"dim1":{"serie":{"index":1}}, ... }}
|
||||
```
|
||||
|
||||
✅ 正确模式:3 张图各自 detached、refs 干净不重叠:
|
||||
|
||||
```jsonc
|
||||
// 图 1:华北
|
||||
{"data":{
|
||||
"headerMode":"detached","direction":"column",
|
||||
"refs":[{"value":"'Sheet'!A2:E9"}],
|
||||
"dim1":{"serie":{"index":1,"nameRef":"'Sheet'!A1"}},
|
||||
"dim2":{"series":[
|
||||
{"index":3,"nameRef":"'Sheet'!C1"},
|
||||
{"index":4,"nameRef":"'Sheet'!D1"}
|
||||
]}
|
||||
}}
|
||||
// 图 2:华东 —— refs 改 'Sheet'!A10:E17,其余同上
|
||||
// 图 3:华南 —— refs 改 'Sheet'!A18:E25,其余同上
|
||||
```
|
||||
|
||||
> `--properties` JSON 关键字段:
|
||||
> - `position.row` / `position.col` 必须留足空间,越界会被 API 拒(按本文件"图表位置选择"四步走)
|
||||
> - `snapshot.data.headerMode`:默认 inline;当 refs 仅覆盖数据子集而语义表头在子集之外,必须 `detached` + `nameRef`
|
||||
> - chart 引用 pivot 输出时,`snapshot.data.refs` 必须排除总计 / 小计行
|
||||
|
||||
### `+chart-update`
|
||||
|
||||
**Update 三步法**(缺一步会丢字段):
|
||||
|
||||
1. `+chart-list --chart-id <id>` 拿到完整 snapshot
|
||||
2. 在拿到的 snapshot 上**局部**修改要改的字段,其余保持不变
|
||||
3. 把**完整 snapshot** 整个回写到 `--properties.snapshot`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
|
||||
--properties '{
|
||||
"position":{"row":0,"col":"A"},
|
||||
"size":{"width":480,"height":320},
|
||||
"snapshot": <完整快照(由 +chart-list 取回后局部修改)>
|
||||
}'
|
||||
```
|
||||
|
||||
> 关键:**不能只提交局部 snapshot**,否则未传字段会被还原为默认值。`+chart-update` 的语义是 PUT(整体覆盖),不是 PATCH。
|
||||
|
||||
### `+chart-delete`
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
# dry-run 先看会删什么(sheet 定位必填)
|
||||
lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \
|
||||
--chart-id "chrXXX" --dry-run
|
||||
|
||||
# 真正执行
|
||||
lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \
|
||||
--chart-id "chrXXX" --yes
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+chart-create` / `+chart-update` 的 `--properties` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。
|
||||
- `DryRun`:`+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板";`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
|
||||
- `Execute`:写操作执行后不自动回读;如需确认,自行调用 `+chart-list` 比对结果。
|
||||
|
||||
> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`(high-risk-write)必须 `--yes`。
|
||||
179
skills/lark-sheets/references/lark-sheets-conditional-format.md
Normal file
179
skills/lark-sheets/references/lark-sheets-conditional-format.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Lark Sheet Conditional Format
|
||||
|
||||
## 真对象硬约束 + 触发词清单
|
||||
|
||||
用户出现以下口语指令时,**强制**走 `+cond-format-{create|update|delete}`,**禁止**用 `+cells-set` 写静态背景色 / 字体色代替:
|
||||
|
||||
- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色 / 表红色 / 表黄色"
|
||||
- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分"
|
||||
- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红"
|
||||
- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变"
|
||||
- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式"
|
||||
|
||||
飞书表格的"颜色标记"语义 = 条件格式规则 ≠ 静态背景色。如果用 `+cells-set` 写静态,源数据变化时颜色不会跟着变(典型反例:用户要求"过期单元格标红"时,模型用静态填充——日期变化后单元格颜色不再准确反映过期状态)。
|
||||
|
||||
**判断标准**:交付后 `+cond-format-list` 必须能返回该规则;否则视为违规。
|
||||
|
||||
**大数据量首选**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,比"本地脚本逐行计算 + `+cells-set` 写静态背景色"更高效、更稳(颜色还能随源数据自动联动)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读写条件格式对象。本 reference 覆盖 4 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看已有条件格式 | `+cond-format-list` | 获取规则类型、范围和样式配置 |
|
||||
| 创建/更新/删除条件格式 | `+cond-format-{create|update|delete}` | 对条件格式规则执行写入操作 |
|
||||
|
||||
典型工作流:先读取现有条件格式了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **创建后必须验证**:条件格式创建后必须调用 `+cond-format-list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试
|
||||
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏
|
||||
- **`style.back_color` vs `style.fore_color` 的中文语义**:用户中文语境下的"**标红/高亮/染色/标记**"指**单元格背景色**,用 `back_color`;"**文字红/字体红/把字变红**"才用 `fore_color`。默认无说明时选 `back_color`。把过期数据涂红、重复值高亮等都应该是 `back_color: "#FFE6E6"`(或类似浅红)配合可选的 `fore_color` 加深字体
|
||||
- **日期/空值比较必须防空**:用户说"过期的标红"时,除了 `TODAY()`,公式必须排除空单元格,否则空白格也会被误判为"早于今天"而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
|
||||
- **公式条件注意引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 而非 `=$E$1<=TODAY()`,后者只比较一个格)
|
||||
|
||||
⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过(高频致命错误)**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成:
|
||||
|
||||
- "**增加辅助列**,再/然后标记……"
|
||||
- "**先计算/判断** XX **是否** YY,**再**标记……"
|
||||
- "**新建一列**放结果,再用结果染色"
|
||||
- 明确要求用 "辅助列"、"辅助字段"、"判断列"、"标记列"
|
||||
|
||||
**正确做法(两步走)**:
|
||||
|
||||
```
|
||||
Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列)
|
||||
range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100"
|
||||
|
||||
Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression)
|
||||
`+cond-format-{create|update|delete}` create
|
||||
rule_type: "expression"
|
||||
ranges: ["A2:H100"] // 整行高亮
|
||||
attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列
|
||||
style: {back_color: "#FFECEC"}
|
||||
```
|
||||
|
||||
**错误做法(一步走绕过辅助列)**:
|
||||
|
||||
```
|
||||
`+cond-format-{create|update|delete}` create
|
||||
rule_type: "expression"
|
||||
ranges: ["2:145"]
|
||||
attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 不满足用户明确要求的"辅助列"诉求
|
||||
```
|
||||
|
||||
为什么禁止一步走:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到"是/否"列;条件格式只是视觉辅助。一步 expression 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整/未采用公式"。
|
||||
|
||||
`expression` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。
|
||||
|
||||
⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`+csv-get range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词),formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+cond-format-list` | read | 对象 |
|
||||
| `+cond-format-create` | write | 对象 |
|
||||
| `+cond-format-update` | write | 对象 |
|
||||
| `+cond-format-delete` | high-risk-write | 对象 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+cond-format-list`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--rule-id` | string | optional | 按规则 id 过滤 |
|
||||
|
||||
### `+cond-format-create`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag |
|
||||
| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 |
|
||||
|
||||
### `+cond-format-update`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--rule-id` | string | required | 目标规则 id |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 |
|
||||
| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) |
|
||||
| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 |
|
||||
|
||||
### `+cond-format-delete`
|
||||
|
||||
_公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--rule-id` | string | required | 目标规则 id |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+cond-format-create` `--properties` / `+cond-format-update` `--properties`
|
||||
|
||||
_创建/更新的条件格式属性_
|
||||
|
||||
**顶层字段**:
|
||||
- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] — ⚠️ 已拎为独立 flag `--rule-type`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
|
||||
- `ranges` (array<string>) — 应用条件格式的 A1 范围列表 — ⚠️ 已拎为独立 flag `--ranges`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
|
||||
- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum }
|
||||
- `attrs` (array<oneOf>?) — 规则参数列表
|
||||
- `has_ref` (boolean?) — 可选
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
|
||||
|
||||
### `+cond-format-list`
|
||||
|
||||
```bash
|
||||
# 列出当前 sheet 全部条件格式规则(拿 rule_id 供 update/delete)
|
||||
lark-cli sheets +cond-format-list --url "..." --sheet-id "$SID"
|
||||
```
|
||||
|
||||
### `+cond-format-create`
|
||||
|
||||
`--rule-type` / `--ranges` 是独立 flag(不要再放 `--properties`);`style` / `attrs` 等结构走 `--properties`:
|
||||
|
||||
```bash
|
||||
# 重复值高亮
|
||||
lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
|
||||
--rule-type duplicateValues --ranges '["A1:A100"]' \
|
||||
--properties '{"style":{"back_color":"#FFD7D7"}}'
|
||||
|
||||
# 数据条
|
||||
lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
|
||||
--rule-type dataBar --ranges '["B2:B100"]' \
|
||||
--properties @rule.json
|
||||
```
|
||||
|
||||
### `+cond-format-update`
|
||||
|
||||
整组覆盖式:先 `+cond-format-list --rule-id <id>` 拿当前完整配置,改后整组传回。
|
||||
|
||||
### `+cond-format-delete`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes
|
||||
```
|
||||
|
||||
> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次原子提交,不要逐个调用。
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。
|
||||
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+cond-format-list --rule-id <id>` 比对规则 / 范围 / 样式。
|
||||
102
skills/lark-sheets/references/lark-sheets-core-operations.md
Normal file
102
skills/lark-sheets/references/lark-sheets-core-operations.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# 飞书表格核心操作:分析、编辑与可视化
|
||||
|
||||
## 概览
|
||||
|
||||
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill,本文用指针引到那里,不重复展开。
|
||||
|
||||
**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属):
|
||||
|
||||
- **本文(core-operations)= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。
|
||||
- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。
|
||||
- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。
|
||||
|
||||
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
|
||||
|
||||
## 铁律(所有编辑类任务必须满足,子 skill 不得放宽)
|
||||
|
||||
1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。
|
||||
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
|
||||
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。
|
||||
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
|
||||
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set),必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`。
|
||||
7. **分组汇总必须用透视表**:"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
|
||||
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。
|
||||
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
|
||||
|
||||
## 推荐工作流程
|
||||
|
||||
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill(避免读一个调一个),本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。
|
||||
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。
|
||||
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**:
|
||||
|
||||
| 用户需求语义 | 路径 |
|
||||
|---|---|
|
||||
| "完善 / 补齐 / 填空 / 修正所有 XX" / 数据分析 / 清洗 / 大数据集 | **A:原生优先**(公式 / `+pivot` / `+filter`,见第 5 步);原生表达不了或更复杂时**分批 `+csv-get` 导出 + 本地脚本处理 + 分批回写**(默认覆盖所有对应数据行,不以用户选区为准;脚本与 CLI 配合见下方「CLI 配合要点」) |
|
||||
| "查一下 / 看看 / 统计 / 汇总" 等只读 | B:`+csv-get` 读到上下文 |
|
||||
| 需要公式 / 样式 / 批注 | C:`+cells-get` |
|
||||
| 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) |
|
||||
|
||||
**【高频致命错误】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。
|
||||
|
||||
4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。
|
||||
|
||||
5. **分析与计算(原生工具优先,代码兜底)**:飞书原生能力能随数据自动更新,**必须优先**:
|
||||
|
||||
| 用户需求 | 必须用的原生工具 | 禁止用代码替代 |
|
||||
|---|---|---|
|
||||
| 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → `+cells-set` |
|
||||
| 求和 / 计数 / 平均 / 占比 | 公式(SUM/COUNT/AVERAGE) | Python 算 → 写静态值 |
|
||||
| 画图表 / 可视化 | `+chart-{create\|update\|delete}` | matplotlib 画图 |
|
||||
| 条件高亮 / 色阶 | `+cond-format-{create\|update\|delete}` | 逐单元格设样式 |
|
||||
| 数据筛选 | `+filter-{create\|update\|delete}` | pandas filter → 覆盖写入 |
|
||||
| 文本提取 / 转换 | 公式(REGEXEXTRACT/TEXT/VALUE) | Python 正则 → 写静态值 |
|
||||
| 查找匹配 | 公式(VLOOKUP/INDEX+MATCH) | pandas merge → 写静态值 |
|
||||
|
||||
**只有以下才用代码**:多步清洗流水线、统计建模、公式试错 3 次仍失败的降级。代码结果回写:大块纯值用 `+csv-put`(+ `--start-cell`,必要时自动扩容);少量或需公式 / 样式用 `+cells-set`;能用飞书公式表达的写飞书公式。
|
||||
|
||||
6. **写入与修改(细节见 `lark-sheets-write-cells`)**:`+cells-set` 的 `range` 必须落在已有行列范围内、`cells` 二维数组与 `range` 严格同维;表尾追加先用 `+dim-insert` 插行列再写;整列 / 整行同结构的值 / 公式 / 格式用模板单元格 + `--copy-to-range`,禁止逐行 `+cells-set`;多步写入合并为 `+batch-update`;改尺寸先读相邻可见行列当前尺寸再决定 `pixel` / `standard` / `auto`,不要猜数值。
|
||||
|
||||
7. **验证**:重新读取受影响区域确认值 / 公式 / 样式 / 批注符合预期;对象类(图表 / 透视表 / 条件格式 / 筛选 / 迷你图 / 浮动图片)重新读对象配置确认;出错先定位错误类型 / 受影响区域 / 根因再修复重验。
|
||||
|
||||
## 用本地代码 / 脚本时的 CLI 配合要点
|
||||
|
||||
复杂处理——多步清洗、统计建模、批量转换、语义任务的分批编排等——用代码(`python` / `node` 等)解决是完全正当的。原生能力(公式 / `+pivot` / `+filter`)能表达就优先用(可随源数据自动重算);原生表达不了或逻辑更复杂时,放手用代码。下面几条让脚本与 CLI 顺畅配合:
|
||||
|
||||
- **解析输出时只读 stdout**:CLI 把数据 JSON 写到 stdout、把诊断与警告写到 stderr。解析 JSON 时**不要合并这两条流**(即不要 `2>&1`),否则警告行混进 JSON 会让解析失败。用管道(`lark-cli … | jq …`)或先把 stdout 单独重定向到文件再读;需要诊断信息时把 stderr 另导到一个文件。
|
||||
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**:BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`。
|
||||
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
|
||||
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
|
||||
|
||||
## 公式策略
|
||||
|
||||
- **公式优先于硬编码**(同铁律 4):能用公式表达的计算一律写公式,源数据变化才能自动重算。
|
||||
- **写任何公式前先读 `lark-sheets-formula-translation`**:它是公式正确性的唯一权威,覆盖绝对引用(`$`)、飞书范围语法(`H:H` 与工具 A1 表示法的区别)、ARRAYFORMULA / 数组语义、Excel 迁移、不支持函数清单等全部规则。本文不再单列这些细则。
|
||||
|
||||
## 常见陷阱(铁律已覆盖的不再重复,仅列易漏点)
|
||||
|
||||
- **合并单元格**:合并区只有左上角存数据,其余读为空是正常行为;写入只能写左上角,写其它位置会报 `cell ... is inside a merged region`。改合并区先取消再操作。安全操作 5 条与"批量取消用大 range 一次调用"见 `lark-sheets-range-operations`。
|
||||
- **`+dim-insert` 不继承行高**:`--inherit-style before/after` 只继承值 / 公式 / 边框,不继承 `row_height`,新行会回落默认高度截断长文本;中间插行填文本前先读相邻行 `row_height`,用 `+batch-update` 合 `+rows-resize` 补齐。
|
||||
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首 5 + 末 5 行查 `#VALUE!` / `#NAME?` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次,超了改代码以值写入。
|
||||
- **循环引用**:聚合公式(SUM/AVERAGE)引用范围不能含目标 cell 自身或其传递依赖。
|
||||
- **NaN / 空值 / 除零**:空值不直接参与运算;除法用 `IF` / `IFERROR` 防零。
|
||||
- **排序 / 筛选混合文本列**:带货币符 / 单位 / 表达式的文本列直接排序 / 筛选会按字典序出错,先抽数值到辅助列再处理(细则见 `lark-sheets-range-operations` / `lark-sheets-filter`)。
|
||||
- **隐藏行列**:`+csv-get` 默认 `--skip-hidden=false`(含隐藏行列);设 `true` 只看可见数据,但返回行序号与实际行号不再对应。
|
||||
- **行号一律取 `[row=N]` 前缀**:`+csv-get` 的 CSV 中双引号内换行是单元格内换行不是新行;禁止数 `\n`、禁止用"序号列"当行号(细则见 `lark-sheets-read-data`)。
|
||||
- **列字母取 `col_indices[j]`**:禁止手数表头逗号定位列(>10 列极易 off-by-one)。
|
||||
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
|
||||
- **`+cells-search` 不是万能**:用户说"汇总金额"是操作动作(求和),不是搜索该文本;只在确需定位某文本位置时才用。
|
||||
|
||||
## 特殊场景
|
||||
|
||||
### 续写 / 复制已有区块格式
|
||||
|
||||
核心要求见铁律 5。机制(带齐哪些样式字段、怎么采样写入)见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」;样式标准(斑马纹奇偶 / 配色 / 边框层级)见 `lark-sheets-visual-standards` 场景二。本文不再展开。
|
||||
|
||||
### NLP 任务处理
|
||||
|
||||
任务涉及语义理解、翻译、改写、摘要、分类、抽取、多行聚合时,以 NLP 方式处理,不要用纯规则代码替代语义理解(但可用代码做分批、行号映射、结果拼装与写回)。数据量大时**必须**分批(通常 30 行一批),每批处理完立即写回,不要全处理完再一次写入;单批生成通常不超 300 行,超出时按性质抽样或分批并向用户说明范围;多批写入优先用 `+batch-update` 合并为原子提交。
|
||||
|
||||
### 格式处理优先公式
|
||||
|
||||
"去除多余零 / 提取数字 / 文本格式转换 / 日期格式化"等清洗,**必须优先用公式**(`SUBSTITUTE` / `TEXT` / `VALUE` / `LEFT` / `RIGHT` / `MID` 等):写一个模板 + `--copy-to-range` 即可整列处理,远比逐行修改高效。
|
||||
@@ -1,133 +0,0 @@
|
||||
# Sheets Dropdown
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总下拉列表配置:
|
||||
|
||||
- `+set-dropdown`
|
||||
- `+update-dropdown`
|
||||
- `+get-dropdown`
|
||||
- `+delete-dropdown`
|
||||
|
||||
> **关键规则:** 使用 `multipleValue` 写入前,必须先设置下拉列表;否则值会被当成纯文本。
|
||||
|
||||
<a id="set-dropdown"></a>
|
||||
## `+set-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +set-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +set-dropdown --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100" --condition-values '["选项1", "选项2", "选项3"]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 范围(如 `<sheetId>!A2:A100`) |
|
||||
| `--condition-values` | 是 | 下拉选项 JSON 数组 |
|
||||
| `--multiple` | 否 | 是否多选 |
|
||||
| `--highlight` | 否 | 是否着色 |
|
||||
| `--colors` | 否 | 颜色 JSON 数组 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`code`、`msg`
|
||||
|
||||
<a id="update-dropdown"></a>
|
||||
## `+update-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +update-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" \
|
||||
--ranges '["<sheetId>!A1:A100"]' \
|
||||
--condition-values '["选项A", "选项B"]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--ranges` | 是 | 范围 JSON 数组 |
|
||||
| `--condition-values` | 是 | 选项 JSON 数组 |
|
||||
| `--multiple` | 否 | 是否多选 |
|
||||
| `--highlight` | 否 | 是否着色 |
|
||||
| `--colors` | 否 | 颜色 JSON 数组 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`spreadsheetToken`、`sheetId`、`dataValidation`
|
||||
|
||||
<a id="get-dropdown"></a>
|
||||
## `+get-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +get-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--range "<sheetId>!A2:A100"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--range` | 是 | 查询范围 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `dataValidations[].conditionValues`
|
||||
- `dataValidations[].ranges`
|
||||
- `dataValidations[].options.multipleValues`
|
||||
- `dataValidations[].options.highlightValidData`
|
||||
- `dataValidations[].options.colorValueMap`
|
||||
|
||||
<a id="delete-dropdown"></a>
|
||||
## `+delete-dropdown`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-dropdown`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-dropdown --spreadsheet-token "shtxxxxxxxx" \
|
||||
--ranges '["<sheetId>!A2:A100", "<sheetId>!C1:C50"]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--ranges` | 是 | 范围 JSON 数组 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:
|
||||
|
||||
- `rangeResults[].range`
|
||||
- `rangeResults[].success`
|
||||
- `rangeResults[].updatedCells`
|
||||
|
||||
## 典型流程
|
||||
|
||||
```bash
|
||||
# 1. 配置下拉
|
||||
lark-cli sheets +set-dropdown --url "<url>" \
|
||||
--range "<sheetId>!J2:J100" --condition-values '["选项1","选项2"]' --multiple
|
||||
|
||||
# 2. 再写入 multipleValue
|
||||
lark-cli sheets +write --url "<url>" --sheet-id "<sheetId>" --range "J2" \
|
||||
--values '[[{"type":"multipleValue","values":["选项1","选项2"]}]]'
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [cell-data](lark-sheets-cell-data.md#write) — 写入普通单元格数据
|
||||
126
skills/lark-sheets/references/lark-sheets-filter-view.md
Normal file
126
skills/lark-sheets/references/lark-sheets-filter-view.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Lark Sheet Filter View
|
||||
|
||||
## 概念回顾
|
||||
|
||||
筛选视图是 sheet 内的多份独立筛选配置,每个视图持有自己的 `range` 和 `rules`,由独立 `view_id`(10 位随机字符串)标识。一个 sheet 可有多个视图,视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者,也不与该 sheet 上可能并存的筛选器(filter)互相影响。
|
||||
|
||||
`+filter-view-{create|update|delete}` 负责视图本身的 CRUD(create / update / delete);视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读写筛选视图对象。本 reference 覆盖 4 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) |
|
||||
| 创建 / 更新 / 删除筛选视图 | `+filter-view-{create|update|delete}` | create / update / delete 三个独立 shortcut |
|
||||
|
||||
典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **视图范围必须覆盖表头行**:视图的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行
|
||||
- **更新前先读取**:用户说"调整这个视图"时,先用 `+filter-view-list` 拉到目标视图当前 rules,**只改差异列**再回写
|
||||
- **多次 create 不能复用 view_id**:复用应走 `update`,重复 `create` 会产生新视图
|
||||
- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+filter-view-list` | read | 对象 |
|
||||
| `+filter-view-create` | write | 对象 |
|
||||
| `+filter-view-update` | write | 对象 |
|
||||
| `+filter-view-delete` | high-risk-write | 对象 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+filter-view-list`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--view-id` | string | optional | 按筛选视图 reference_id 过滤(命中即只返回单个视图) |
|
||||
|
||||
### `+filter-view-create`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag |
|
||||
| `--range` | string | required | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 |
|
||||
| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
|
||||
|
||||
### `+filter-view-update`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--view-id` | string | required | 目标筛选视图 reference_id |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`;update 是整组覆盖式(先 `+filter-view-list` 回读再 patch;传空 `rules: []` 清空)。`range` 和 `view_name` 是独立 flag |
|
||||
| `--range` | string | optional | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range |
|
||||
| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
|
||||
|
||||
### `+filter-view-delete`
|
||||
|
||||
_公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--view-id` | string | required | 目标筛选视图 reference_id |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+filter-view-create` `--properties` / `+filter-view-update` `--properties`
|
||||
|
||||
_create / update 的视图属性_
|
||||
|
||||
**顶层字段**:
|
||||
- `view_name` (string?) — 可选 — ⚠️ 已拎为独立 flag `--view-name`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
|
||||
- `range` (string?) — 视图作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
|
||||
- `rules` (array<object>?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
|
||||
- `filtered_columns` (array<string>?) — 可选
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。
|
||||
|
||||
### `+filter-view-list`
|
||||
|
||||
```bash
|
||||
# 列出某个 sheet 的全部筛选视图
|
||||
lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID"
|
||||
|
||||
# 按 view_id 精确定位
|
||||
lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde1234
|
||||
```
|
||||
|
||||
### `+filter-view-create`
|
||||
|
||||
`--range`(必填)/ `--view-name`(可选)是独立 flag;`rules` 走 `--properties`:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
|
||||
--view-name "活跃用户" --range "A1:F1000" \
|
||||
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
|
||||
```
|
||||
|
||||
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
|
||||
|
||||
### `+filter-view-update`
|
||||
|
||||
> ⚠️ update 是整组覆盖(PUT 语义):`--properties` **必传**,未在请求里出现的 rules / filtered_columns 会被清空。如要保留已有 rules,先 `+filter-view-list` 读回再合并写回。`--range` 变更会丢弃已有筛选规则属预期行为(rules 跟当前 range 绑定)。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。
|
||||
|
||||
### `+filter-view-delete`
|
||||
|
||||
> ⚠️ 删除**已存在**的视图不可逆;目标 view_id **不存在**时按幂等成功返回(不报错)。先 `--dry-run` 看 view_id 确认。
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,`--properties` 必传(整组覆盖式);`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。
|
||||
- `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-view-list --view-id <new>` 比对当前 range + rules。
|
||||
@@ -1,193 +0,0 @@
|
||||
# Sheets Filter Views
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总筛选视图和筛选条件:
|
||||
|
||||
- `+create-filter-view`
|
||||
- `+update-filter-view`
|
||||
- `+list-filter-views`
|
||||
- `+get-filter-view`
|
||||
- `+delete-filter-view`
|
||||
- `+create-filter-view-condition`
|
||||
- `+update-filter-view-condition`
|
||||
- `+list-filter-view-conditions`
|
||||
- `+get-filter-view-condition`
|
||||
- `+delete-filter-view-condition`
|
||||
|
||||
<a id="create-filter-view"></a>
|
||||
## `+create-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +create-filter-view`
|
||||
|
||||
在工作表中创建筛选视图,每个工作表最多 150 个。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14"
|
||||
|
||||
lark-cli sheets +create-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --range "<sheetId>!A1:H14" --filter-view-name "我的筛选"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--range` | 是 | 筛选范围 |
|
||||
| `--filter-view-name` | 否 | 显示名称 |
|
||||
| `--filter-view-id` | 否 | 自定义 10 位字母数字 ID |
|
||||
|
||||
输出:`filter_view`
|
||||
|
||||
<a id="update-filter-view"></a>
|
||||
## `+update-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +update-filter-view`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --range "<sheetId>!A1:J20"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--range` | 否 | 新范围 |
|
||||
| `--filter-view-name` | 否 | 新显示名称 |
|
||||
|
||||
<a id="list-filter-views"></a>
|
||||
## `+list-filter-views`
|
||||
|
||||
对应命令:`lark-cli sheets +list-filter-views`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-views --spreadsheet-token "shtxxxxxxxx" --sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
输出:`items[]`(`filter_view_id`、`filter_view_name`、`range`)
|
||||
|
||||
<a id="get-filter-view"></a>
|
||||
## `+get-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +get-filter-view`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
输出:`filter_view`
|
||||
|
||||
<a id="delete-filter-view"></a>
|
||||
## `+delete-filter-view`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-filter-view`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
|
||||
<a id="create-filter-view-condition"></a>
|
||||
## `+create-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +create-filter-view-condition`
|
||||
|
||||
为筛选视图的指定列创建筛选条件。
|
||||
|
||||
```bash
|
||||
# 数值筛选:E 列 < 6
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "E" --filter-type "number" --compare-type "less" --expected '["6"]'
|
||||
|
||||
# 文本筛选:G 列以 a 开头
|
||||
lark-cli sheets +create-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" \
|
||||
--condition-id "G" --filter-type "text" --compare-type "beginsWith" --expected '["a"]'
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--sheet-id` | 是 | 工作表 ID |
|
||||
| `--filter-view-id` | 是 | 筛选视图 ID |
|
||||
| `--condition-id` | 是 | 列字母,如 `E` |
|
||||
| `--filter-type` | 是 | `hiddenValue` / `number` / `text` / `color` |
|
||||
| `--compare-type` | 否 | 比较运算符 |
|
||||
| `--expected` | 是 | 筛选值 JSON 数组 |
|
||||
|
||||
输出:`condition`
|
||||
|
||||
<a id="update-filter-view-condition"></a>
|
||||
## `+update-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +update-filter-view-condition`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E" \
|
||||
--filter-type "number" --compare-type "between" --expected '["2","10"]'
|
||||
```
|
||||
|
||||
参数与创建条件相同,但 `filter-type` / `compare-type` / `expected` 可按需部分更新。
|
||||
|
||||
<a id="list-filter-view-conditions"></a>
|
||||
## `+list-filter-view-conditions`
|
||||
|
||||
对应命令:`lark-cli sheets +list-filter-view-conditions`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-filter-view-conditions --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>"
|
||||
```
|
||||
|
||||
输出:`items[]`
|
||||
|
||||
<a id="get-filter-view-condition"></a>
|
||||
## `+get-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +get-filter-view-condition`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
输出:`condition`
|
||||
|
||||
<a id="delete-filter-view-condition"></a>
|
||||
## `+delete-filter-view-condition`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-filter-view-condition`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-filter-view-condition --spreadsheet-token "shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --filter-view-id "<fvId>" --condition-id "E"
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [dropdown](lark-sheets-dropdown.md) — 需要下拉值配合筛选时
|
||||
- [cell-data](lark-sheets-cell-data.md#find) — 只查数据时用 `+find`
|
||||
119
skills/lark-sheets/references/lark-sheets-filter.md
Normal file
119
skills/lark-sheets/references/lark-sheets-filter.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Lark Sheet Filter
|
||||
|
||||
## 真对象硬约束 + 数量校验
|
||||
|
||||
1. **真对象**:当用户要求"筛选 / 只看 / 仅保留 X"时,**必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象。**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。
|
||||
2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用本地脚本在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。
|
||||
3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读写筛选器对象。本 reference 覆盖 4 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看已有筛选器 | `+filter-list` | 获取筛选器的范围、规则和条件配置 |
|
||||
| 创建/更新/删除筛选器 | `+filter-{create|update|delete}` | 对筛选器执行写入操作 |
|
||||
|
||||
典型工作流:先读取现有筛选器了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
|
||||
|
||||
**只读场景例外**:用户只是想知道哪些数据满足条件、并不要求修改表格展示时,可以走 `lark-sheets-read-data` 读后文本回答,不必创建筛选器。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **筛选范围必须覆盖表头行**:筛选器的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行。缺少表头会导致筛选条件无法正确匹配列
|
||||
- **更新已有筛选器前先读取**:如果子表上已存在筛选器,直接创建会报错或覆盖原有配置。应先用 `+filter-list` 查看是否存在筛选器,存在时使用 update 而非 create
|
||||
- **筛选条件的列索引要精确**:筛选条件中的列标识必须与实际数据列精确对应,不要凭猜测填写
|
||||
- **”调整筛选逻辑”要先读旧配置**:用户说”调整筛选”时,先读取现有筛选器的完整配置,理解当前规则后再修改,不要从零创建
|
||||
- **创建后必须验证**:调用 `+filter-list` 确认筛选器配置正确且生效
|
||||
- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+filter-list` | read | 对象 |
|
||||
| `+filter-create` | write | 对象 |
|
||||
| `+filter-update` | write | 对象 |
|
||||
| `+filter-delete` | high-risk-write | 对象 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+filter-list`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
_仅含公共 / 系统 flag。_
|
||||
|
||||
### `+filter-create`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | optional | 筛选规则 JSON:`rules`(列级筛选规则数组)+ `filtered_columns?`(激活列索引提示)。`--properties` 整体可选——传它时 `rules` 不可为空;不传则只在 `--range` 上建立空筛选器(无列条件)。`range` 是独立 flag(不要再放此 JSON 里) |
|
||||
|
||||
### `+filter-update`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag |
|
||||
| `--range` | string | required | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 |
|
||||
|
||||
### `+filter-delete`
|
||||
|
||||
_公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
_仅含公共 / 系统 flag。_
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+filter-create` `--properties` / `+filter-update` `--properties`
|
||||
|
||||
_创建/更新的筛选器属性_
|
||||
|
||||
**顶层字段**:
|
||||
- `range` (string) — 筛选对象作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
|
||||
- `rules` (array<object>) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
|
||||
- `filtered_columns` (array<string>?) — 可选
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`filter_id` 等同于 `sheet_id`(每个工作表至多一个筛选器)。
|
||||
|
||||
### `+filter-list`
|
||||
|
||||
```bash
|
||||
# 查看当前 sheet 的筛选器配置(filter_id 等于 sheet_id)
|
||||
lark-cli sheets +filter-list --url "..." --sheet-id "$SID"
|
||||
```
|
||||
|
||||
### `+filter-create`
|
||||
|
||||
`--range` 是独立 flag(含表头行);`rules` 走 `--properties`:
|
||||
|
||||
```bash
|
||||
lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
|
||||
--range "A1:F1000" \
|
||||
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
|
||||
```
|
||||
|
||||
### `+filter-update`
|
||||
|
||||
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。
|
||||
|
||||
### `+filter-delete`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +filter-delete --url "..." --sheet-id "$SID" --yes
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes` 或 `--dry-run`。
|
||||
- `DryRun`:输出"将要 POST/PATCH/DELETE 的 filter 请求模板"。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+filter-list` 查看当前筛选条件 + 已过滤行数。
|
||||
158
skills/lark-sheets/references/lark-sheets-float-image.md
Normal file
158
skills/lark-sheets/references/lark-sheets-float-image.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Lark Sheet Float Image
|
||||
|
||||
> **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具:
|
||||
> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark-sheets-write-cells)。
|
||||
> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-image-{create|update|delete}`。
|
||||
|
||||
## 真对象硬约束
|
||||
|
||||
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 reference 覆盖 4 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 查看已有浮动图片 | `+float-image-list` | 获取浮动图片的位置、大小和层级配置 |
|
||||
| 创建/更新/删除浮动图片 | `+float-image-{create|update|delete}` | 对浮动图片执行写入操作 |
|
||||
|
||||
典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **单元格图片 vs 浮动图片选择错误**:如果用户希望图片嵌入单元格内部(随单元格移动),应使用 `+cells-set` 的 `rich_text` + `embed-image`,而非本 Skill
|
||||
- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据
|
||||
- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确
|
||||
|
||||
图片来源有三种方式,`+float-image-create` 上三者 **XOR、必给其一**(`--image` / `--image-token` / `--image-uri`):
|
||||
|
||||
- **`--image <本地路径>`(首选,最省事)**:直接给本地图片文件路径(PNG/JPEG/GIF/BMP/HEIC 等)。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag:必须是当前工作目录内的相对路径(绝对路径会被 Validate 拒,`--dry-run` 也会拦)。
|
||||
- `--image-token`:复用**已存在**的图片 file_token。常见来源:① `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。
|
||||
- `--image-uri`:图片 reference_id(image URI),由系统自动转 file_token。
|
||||
|
||||
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`manage_float_image` 工具强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
| --- | --- | --- |
|
||||
| `+float-image-list` | read | 对象 |
|
||||
| `+float-image-create` | write | 对象 |
|
||||
| `+float-image-update` | write | 对象 |
|
||||
| `+float-image-delete` | high-risk-write | 对象 |
|
||||
|
||||
## Flags
|
||||
|
||||
### `+float-image-list`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--float-image-id` | string | optional | 按 id 过滤;省略时列工作表全部 |
|
||||
|
||||
### `+float-image-create`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) |
|
||||
| `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
|
||||
| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id |
|
||||
| `--position-row` | int | required | 图片左上角所在行(0-based) |
|
||||
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) |
|
||||
| `--size-width` | int | required | 图片宽度(像素) |
|
||||
| `--size-height` | int | required | 图片高度(像素) |
|
||||
| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) |
|
||||
| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) |
|
||||
| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 |
|
||||
| `--image` | string | xor | 本地图片路径(PNG/JPEG 等);CLI 自动上传为 sheet_image 并用返回的 file_token,省去手动拿 token(与 --image-token / --image-uri 三选一) |
|
||||
|
||||
### `+float-image-update`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--float-image-id` | string | required | 目标图片 id |
|
||||
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) |
|
||||
| `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
|
||||
| `--image-uri` | string | xor | 图片 reference_id(与 `--image-token` 二选一);图片上传链路返回的 reference_id |
|
||||
| `--position-row` | int | required | 图片左上角所在行(0-based) |
|
||||
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) |
|
||||
| `--size-width` | int | required | 图片宽度(像素) |
|
||||
| `--size-height` | int | required | 图片高度(像素) |
|
||||
| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) |
|
||||
| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) |
|
||||
| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 |
|
||||
|
||||
### `+float-image-delete`
|
||||
|
||||
_公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--float-image-id` | string | required | 目标图片 id |
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。浮动图片是 sheet 级对象——和单元格内嵌图片不同(后者走 `+cells-set`)。
|
||||
|
||||
### `+float-image-list`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +float-image-list --url "..." --sheet-id "$SID"
|
||||
```
|
||||
|
||||
### `+float-image-create`
|
||||
|
||||
所有字段拍平为独立 flag:图片来源 `--image` / `--image-token` / `--image-uri`(三选一 XOR)/ `--image-name` / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。
|
||||
|
||||
```bash
|
||||
# 首选:直接给本地图片路径,CLI 自动上传(无需手动拿 token)
|
||||
# 注意:--image-name 是 required(即使路径 basename 已经是 logo.png 也要显式传)
|
||||
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
|
||||
--image ./logo.png --image-name "logo.png" \
|
||||
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
|
||||
|
||||
# 用已有 file_token(从 +float-image-list 的 image_token 或 +cells-set-image 返回的 file_token)
|
||||
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
|
||||
--image-name "logo.png" --image-token "$TOKEN" \
|
||||
--position-row 0 --position-col A --size-width 200 --size-height 150
|
||||
|
||||
# 用 reference_id(图片上传链路返回的 image reference_id;与 --image-token 二选一)
|
||||
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
|
||||
--image-name "logo.png" --image-uri "$IMAGE_URI" \
|
||||
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
|
||||
```
|
||||
|
||||
### `+float-image-update`
|
||||
|
||||
> **update ≈ create,只有图片源可省**:`manage_float_image` 工具的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch:缺任一核心字段会被工具拒绝(`+float-image-list` 不回传 `image_name`,CLI 无法替你回填)。
|
||||
>
|
||||
> 推荐流程:先 `+float-image-list --float-image-id <id>` 回读当前 position / size,再带上 `--image-name` 和完整的 position / size 调一次 `+float-image-update`。
|
||||
|
||||
```bash
|
||||
# 调整位置 + 尺寸,保留原图(不传图片源)
|
||||
lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \
|
||||
--float-image-id "$IMG_ID" --image-name "logo.png" \
|
||||
--position-row 5 --position-col C --size-width 300 --size-height 200
|
||||
|
||||
# 换图:额外带 --image-token,核心字段同样要给全
|
||||
lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \
|
||||
--float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" \
|
||||
--position-row 5 --position-col C --size-width 300 --size-height 200
|
||||
```
|
||||
|
||||
### `+float-image-delete`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image-id "$IMG_ID" --yes
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,并和 create 一样必填 `--image-name` / `--position-{row,col}` / `--size-{width,height}`(缺任一核心字段本地直接报错,不会静默发 0);图片源 `--image-token` / `--image-uri` 可省(省略保留原图),给则二选一;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。
|
||||
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+float-image-list --float-image-id <id>` 比对新位置 / 尺寸。
|
||||
@@ -1,125 +0,0 @@
|
||||
# Sheets Float Images
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
这份 reference 汇总浮动图片相关能力:
|
||||
|
||||
- `+media-upload`
|
||||
- `+create-float-image`
|
||||
- `+update-float-image`
|
||||
- `+get-float-image`
|
||||
- `+list-float-images`
|
||||
- `+delete-float-image`
|
||||
|
||||
<a id="media-upload"></a>
|
||||
## `+media-upload`
|
||||
|
||||
对应命令:`lark-cli sheets +media-upload`
|
||||
|
||||
把本地图片上传到指定电子表格的素材空间,返回 `file_token`,供 `+create-float-image` 使用。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +media-upload --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--file ./image.png
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 内部调用 `drive/v1/medias/upload_all`
|
||||
- `>20MB` 自动分片上传
|
||||
- `--file` 只能是当前工作目录下的相对路径
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 否 | 电子表格 URL(与 `--spreadsheet-token` 二选一) |
|
||||
| `--spreadsheet-token` | 否 | 表格 token |
|
||||
| `--file` | 是 | 本地图片路径,必须是相对路径 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
输出:`file_token`、`file_name`、`size`、`spreadsheet_token`
|
||||
|
||||
<a id="create-float-image"></a>
|
||||
## `+create-float-image`
|
||||
|
||||
对应命令:`lark-cli sheets +create-float-image`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +create-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-token "boxcnXXXX" \
|
||||
--range "<sheetId>!A1:A1" --width 200 --height 150
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
- `--float-image-token` 必须来自 `+media-upload`
|
||||
- `--range` 必须锚定单个单元格
|
||||
- `width` / `height` 必须 `>=20`
|
||||
- `offset-x` / `offset-y` 必须 `>=0`
|
||||
|
||||
输出:`float_image`
|
||||
|
||||
<a id="update-float-image"></a>
|
||||
## `+update-float-image`
|
||||
|
||||
对应命令:`lark-cli sheets +update-float-image`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +update-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-id "fi12345678" \
|
||||
--width 400 --height 300 --offset-y 20
|
||||
```
|
||||
|
||||
至少需要传一个更新字段:`--range` / `--width` / `--height` / `--offset-x` / `--offset-y`
|
||||
|
||||
输出:更新后的 `float_image`
|
||||
|
||||
<a id="get-float-image"></a>
|
||||
## `+get-float-image`
|
||||
|
||||
对应命令:`lark-cli sheets +get-float-image`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +get-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-id "fi12345678"
|
||||
```
|
||||
|
||||
输出:`float_image`
|
||||
|
||||
<a id="list-float-images"></a>
|
||||
## `+list-float-images`
|
||||
|
||||
对应命令:`lark-cli sheets +list-float-images`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +list-float-images --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>"
|
||||
```
|
||||
|
||||
输出:`items[]`
|
||||
|
||||
<a id="delete-float-image"></a>
|
||||
## `+delete-float-image`
|
||||
|
||||
对应命令:`lark-cli sheets +delete-float-image`
|
||||
|
||||
```bash
|
||||
lark-cli sheets +delete-float-image --url "https://example.larksuite.com/sheets/shtxxxxxxxx" \
|
||||
--sheet-id "<sheetId>" --float-image-id "fi12345678"
|
||||
```
|
||||
|
||||
输出:`code`、`msg`
|
||||
|
||||
## 读取图片内容
|
||||
|
||||
上述读接口只返回元数据,不返回图片字节。要读取图片内容,用 `float_image_token` 调:
|
||||
|
||||
```bash
|
||||
lark-cli docs +media-preview --token "<float_image_token>" --output ./image.png
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [cell-images](lark-sheets-cell-images.md) — 写入到单元格的图片
|
||||
- [spreadsheet-management](lark-sheets-spreadsheet-management.md#info) — 先获取 `sheet_id`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user