mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
bd898a1d74
* feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts
* docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:
- write-cells: sanity-check units / dimension conversion / quantity factors
before formula writes (formulas can run clean yet be off by a factor);
keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
update" is not explicitly requested; scope rewrite/transform precisely so
rows/columns that should stay unchanged are kept 1:1; treat header-stated
format rules as checklist items; confirm the artifact file actually exists
before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
display (scientific notation collapses distinct numbers and causes false
duplicates)
- chart: format date/number axes via source-cell number_format; place charts
outside the data area so they do not cover existing data
* feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
read-data / workbook / chart updates)
* docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.
- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
(or +workbook-create --sheets when the workbook does not exist yet);
plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
of the create-empty-then-table-put two-step
Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
* docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
* docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
* feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
* refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
* docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
shrink the widths of existing columns (any blanket resize, even by 1px,
breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
a percentage (pie, or stack.percentage on bar/column) rather than raw counts
* docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
* docs: add lark sheets financial modeling guidance
* docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
* docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
* docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
* docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:
1. chart: label position.row as 0-based (first row = row:0), distinct
from the 1-based row numbers used by A1 ranges and +dim-insert
--position, removing the row-base ambiguity.
2. chart: convert the three runnable examples whose JSON contains a
quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
--properties '{...}' to a stdin heredoc (--properties - <<'JSON').
Inside an inline single-quoted string bash strips the inner quotes
around the sheet name (and splits names with spaces into words),
corrupting the JSON; a quoted heredoc delimiter performs no shell
substitution and preserves it. Adds a short note on the pitfall.
3. filter / filter-view: add the full conditions[].type x compare_type
enum table (text / number / multiValue / color and their respective
compare_type values and values shape), and call out the
equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
previously only showed two values via examples.
* docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
* fix(sheets): regenerate flag defs and fix asasalint in table io
* feat(sheets): add counta to chart aggregateType enum
Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.
* feat(sheets): make --target-position and --range mutually exclusive on +pivot-create
Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".
Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.
* feat(sheets): rework +workbook-create flags and --styles
- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)
* fix(sheets): add mention_type enum to set_cell_range cells schema
Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").
- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)
* feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create
Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.
isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.
* feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create
Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.
* docs(lark-sheets): remove financial modeling standards reference
Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.
* docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references
Synced from sheet-skill-spec.
- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.
* feat(sheets): add --styles to +table-put for one-step typed write with styling
+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.
Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).
Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).
* docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification
skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.
Synced from sheet-skill-spec.
* feat(sheets): document link requirement for @document mentions in cells flag schema
@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.
* fix(sheets): reject cond-format attrs whose shape mismatches rule_type
A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).
The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.
Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.
* test(sheets): guard condFormatAttrsRequired against flag-schemas drift
Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.
* fix(sheets): default +table-get to full used range, not A1 current region
+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).
- Probe the used range over the full physical grid (row_count × column_count
from the workbook structure) so it spans internal blank rows/columns; fall
back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.
* docs(sheets): fix csv-get current_region guidance to cross-check row_count
current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.
- current_region: warn it can be both smaller (internal blank rows truncate)
and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
(= actual_range), not the physical sheet size; has_more only reflects the
current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
physical row_count and probe past current_region's last row for data beyond an
internal blank row.
* fix(sheets): collapse duplicate validateCreateInput from bad merge resolution
A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).
Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.
* refactor(sheets): migrate legacy error helpers to typed errs in sheets domain
golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.
- Replace every common.FlagErrorf with common.ValidationErrorf (typed
*errs.ValidationError, same signature) across workbook / table_io /
dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
unchanged (re-wrapping would downgrade classification) and attaches the
failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
layer re-wraps into typed validation errors (buildTypedCell / Arrow
decode-encode) or surfaces as a partial_success message string
(writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
with that reason, per the lint guidance.
No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.
* fix(shortcuts): clarify single-stdin constraint in flag help and error hint
Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.
- Reword the generated help hint to '- reads stdin (one flag per call;
use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.
* feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000
放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。
Synced from sheet-skill-spec@93f7a78.
* docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签
源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。
* docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引
源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。
* feat(sheets): 写操作报错增强 + --token 别名
- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动
* docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导
* fix(sheets): migrate +table-put to typed error contract
The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:
- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
carrying written_sheets, returning the partial-failure exit signal
Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)
* docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段)
* feat(sheets): styles 接受 halign/valign 等对齐字段别名
把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。
* feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.
Mirror the existing slides/doc/drive two-stage pattern:
- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
The wiki:node:read scope is enforced on this path only, so non-wiki
invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
the node_token through unchanged.
All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.
Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).
* docs(sheets): note --url accepts wiki URLs (synced from spec)
* fix(sheets): match --url path segment via url.Parse, not substring
parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.
* fix(sheets): satisfy errorlint/copyloopvar + regen flag defs
- helpers_test.go: drop the Go 1.22+ redundant `tc := tc` loop copy
(copyloopvar).
- lark_sheet_dataframe.go, lark_sheet_table_io.go: switch the
intermediate-error fmt.Errorf calls from %v to %w so errorlint passes.
Behavior unchanged — these errors are always rewrapped into typed
validation errors at the command layer.
- flag_defs_gen.go: regenerate from data/flag-defs.json (drift from the
wiki-URL merge).
* ci: allow Apache Arrow module in license check
Arrow is Apache-2.0 overall, but it vendors c-ares (LicenseRef-C-Ares,
ISC-like) inside the module which go-licenses classifies as Unknown and
the strict disallowed_types=...,unknown gate rejects.
Pass --ignore github.com/apache/arrow/go/v17 since Arrow is required by
sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC
ingest) and the vendored c-ares is not redistributed by us.
* fix(sheets): resolve wiki URL in +range-move/+range-copy Execute
transformExecuteFn (the named Execute helper shared by +range-move and +range-copy) still called the network-free resolveSpreadsheetToken, so a /wiki/ URL reached transform_range as an unresolved node_token and failed. #1519's sweep over Execute hooks only rewrote inline closures; this is the only Execute backed by a named helper. Switch it to resolveSpreadsheetTokenExec (Validate/DryRun stay network-free) and add a +range-move wiki-URL regression test.
* refactor(sheets): drop +table-put manual capacity grow; rely on set_cell_range auto-grow
set_cell_range now auto-grows the sub-sheet to fit the write, so the
ensureSheetCapacity helper (and its modify_sheet_structure dim-insert
call before each write) is no longer needed. This also closes a data-
safety hole flagged in review: inserting before the last existing row
could push real data down into the area set_cell_range was about to
write, and allow_overwrite=false could not protect against it because
the structural insert had already mutated the sheet by the time the
write-collision check ran.
Verified end-to-end against a real spreadsheet: +table-put writing
300x25 into a fresh Sheet1 (default 200x20) succeeds in one write and
the sheet ends up 301x25.
* fix(sheets): close --dataframe stdin guard hole
--dataframe is binary and bypasses the common Input resolver, which is
where the existing single-stdin guard lives. Result: an invocation like
+table-put --dataframe - --styles - was accepted, then one of the two
consumers raced for stdin and the other silently saw an empty stream.
Add a stdinConsumed marker on RuntimeContext that both consumers share:
common.resolveInputFlags sets it when an Input flag uses '-', and
readDataframeBytes both checks and sets it. A second consumer is
rejected up front with an actionable hint pointing at @file.
Flagged in code review (lark_sheet_dataframe.go:93).
* fix(sheets): harden +table-put / +table-get input validation and round-trip safety
Four review-flagged correctness gaps in table I/O, all bundled because
they touch the same file:
1. --sheets accepted trailing data after the first JSON value
(json.Decoder does not surface that, unlike json.Unmarshal). A new
decoderExpectEOF helper rejects e.g. `--sheets '{...} oops'` with a
typed validation error instead of letting the leading object pass
through and surface as a confusing downstream failure.
2. +table-get with a duplicate header (e.g. `amount, amount`) used to
read back successfully — the dtypes map silently collapsed to one
entry — and only failed later on +table-put because the writer
rejects duplicate column names. Fail fast at read time with an
actionable hint to rename or pass --no-header. --no-header mode is
exempt (fallback col<N> names are always unique).
3. +table-put dry-run rendered an invalid range like A1:C0 when
header=false with rows=[]. tablePutFullRange returns "" for an
empty matrix or zero columns instead of building a degenerate
rectangle.
4. +table-get with --sheet-id and a get_workbook_structure miss (read
failure or selector mismatch) used to return a target with
name="", which then broke +table-get → +table-put round-trip (the
writer requires a non-empty sheet name). Fall back to using the id
as the name.
End-to-end verified against a real spreadsheet: trailing data, duplicate
header, and --no-header fallback all behave as advertised.
* fix(sheets): apply +workbook-create style-only ops instead of silently dropping them
A +workbook-create call carrying only cell_merges / row_sizes / col_sizes
(no --values / --sheets and no cell_styles) used to create the workbook
but silently drop the requested visual ops. Two reasons, both fixed:
- workbookCreateStyleDimensions only counted cell_styles when computing
the write extent, so cell_merges / row_sizes / col_sizes always
contributed 0 → buildValuesPayload returned a nil payload → Execute
skipped writeTypedSheets entirely → no visual ops ran. Extend the
helper to fold the merge / resize ranges in.
- Pure row_sizes / col_sizes payloads can never expand a cell rectangle
(they are dimension ranges, not cell ranges), so even with the extent
fix Execute would still skip the write path. Add a no-data branch:
when payload == nil but a styles item is present, look up the default
sheet and apply visual ops directly via applyWorkbookCreateVisualOps.
The dry-run plan mirrors this so the preview shows the visual ops.
Also picks up the --values trailing-JSON-data EOF check (mirror of the
--sheets one in lark_sheet_table_io.go).
End-to-end verified against a real spreadsheet: a cell_merges-only
+workbook-create now produces a sheet with merged_cells_count: 1.
* fix(sheets): preserve causes and render messages cleanly for typed validation errors
common.ValidationErrorf goes through fmt.Sprintf, which does not support
%w — the seven call sites that used `%w` were rendering the cause as
literal `%!w(*fmt.wrapError=&{...})` and dropping the cause from the
typed-error chain (so callers couldn't errors.As back to the underlying
error).
Switch each to `%v` for clean rendering and attach the cause via
.WithCause(err) so the typed contract is preserved. Touched call sites:
- lark_sheet_dataframe.go: --dataframe Arrow decode / stdin read / file
read failures (3 call sites).
- lark_sheet_table_io.go: --sheets invalid JSON, payload-validate
per-cell coercion error, buildSheetMatrix per-cell error,
--dataframe-out arrow encode failure (4 call sites).
End-to-end verified against a real spreadsheet: both invalid-JSON and
typed-cell errors now render readable messages instead of %!w(...).
* sync(sheets): pick up +sheet-{show,hide}-gridline in +batch-update schema
Mirror of the sheet-skill-spec change adding the two gridline shortcuts
to cli-schemas.json batch_update.operations.shortcut enum. Synced from
the upstream canonical via generate:cli + sync:cli.
Verified end-to-end on a real spreadsheet — +batch-update with a
+sheet-hide-gridline op passes schema validation and the backend run
returns succeeded: 1.
* sync(sheets): pick up +workbook-export UX clarification from spec
Mirror of the sheet-skill-spec update that documents +workbook-export's
default-no-download behavior and its relationship to drive +export
--doc-type sheet. Synced from canonical via generate:cli + sync:cli +
go generate.
End-to-end verified against a real spreadsheet:
- Omit --output-path → ok:true, downloaded:false, file_token returned
- Pass --output-path ./crfix_test.xlsx → ok:true, file saved
(17892 bytes), saved_path returned
The --help output for +workbook-export now states the default behavior
and points callers at `drive +export --doc-type sheet` when they need
the --output-dir / --file-name / --overwrite split.
* test(sheets): assert typed errs.Problem instead of err.Error() substrings
Per the coding guideline "Error-path tests must assert typed metadata via
errs.ProblemOf (category / subtype / param) and cause preservation, not
message substrings alone." — sweep through every error-path assertion in
the sheets domain and replace the
`strings.Contains(stdout+stderr+err.Error(), ...)` pattern with two
small helpers landed in helpers_test.go:
requireProblem(t, err, wantCategory, wantSubtype, msgContains)
-> *errs.Problem
requireValidation(t, err, msgContains)
-> *errs.ValidationError // shorthand for CategoryValidation +
// SubtypeInvalidArgument; lets callers
// also assert .Param / .Params / .Cause
~60 assertion sites across 18 test files now check the typed envelope
shape, with message-substring checks moved onto the returned Problem
(.Message / .Hint / .Param). The substring is preserved as a sanity
check rather than the sole assertion, so a category drift like
validation → internal would now fail loudly instead of slipping past.
Cases intentionally left as substring (each with a one-line reason):
- Errors that come straight from cobra's native flag parser (untyped
*errors.errorString — e.g. "required flag(s) ... not set", mutually-
exclusive groups). Re-typing these needs a custom FlagErrorFunc and
is out of scope here.
- Intermediate errors from decodeArrowToSheet that the caller wraps
into a typed envelope (`//nolint:forbidigo` reason). Those unit
tests assert the unwrapped intermediate directly.
One production tweak:
- shortcuts/sheets/flag_schema.go: printFlagSchemaFor returns typed
*errs.ValidationError (with WithParam("--flag-name") on the
unknown-flag branch) instead of raw fmt.Errorf. The framework
already wraps this when called via --print-schema, so user-facing
behaviour is unchanged; direct callers (and tests) now get the
typed envelope.
Verified: go test ./shortcuts/sheets/... passes; golangci-lint
--new-from-rev=origin/main reports 0 issues.
* test(common): assert typed errs.Problem instead of err.Error() substrings
Mirror of the sweep just landed in shortcuts/sheets: replace error-path
substring assertions with typed-envelope checks via two small helpers
landed in a new shortcuts/common/typed_error_assertions_test.go:
requireProblem(t, err, wantCategory, wantSubtype, msgContains)
-> *errs.Problem
requireValidation(t, err, msgContains)
-> *errs.ValidationError // shorthand for CategoryValidation +
// SubtypeInvalidArgument; lets callers
// also assert .Param / .Params / .Cause
8 sites moved to typed assertions across runner_jq_test.go,
mcp_client_test.go, drive_media_upload_typed_test.go, and
runner_input_test.go (the input tests already used a typed-param helper;
this just retargets the substring follow-up onto the typed Message).
Sites intentionally left as substring + comment (production returns raw
fmt.Errorf, not a typed envelope):
- runner_botinfo_test.go (6 sites): BotInfo / fetchBotInfo wrap upstream
errors with fmt.Errorf so the SDK-level message ([99991], 403,
invalid character, etc.) shows through.
- runner_args_test.go (4 sites in 2 tests): rejectPositionalArgs returns
raw fmt.Errorf to satisfy cobra's PositionalArgs contract.
- permission_grant_test.go (2 sites): assert on stderr / hint strings,
not error messages — already out of the err.Error() substring class.
No production code changes.
Verified: go test ./shortcuts/common/... passes;
golangci-lint --new-from-rev=origin/main ./shortcuts/common/... reports
0 issues.
* fix(sheets): plug four +table-put / +table-get correctness gaps flagged in CR
Four review-flagged bugs, all in lark_sheet_table_io.go (bundled because
they touch the same file and the same +table-put / +table-get domain):
1. +table-get --dry-run dropped the --sheet-id / --sheet-name selector
from the get_cell_ranges body, while Execute always passed it. Agents
that validate the dry-run shape and then run live would see a request
shape mismatch. The dry-run now calls sheetSelectorForToolInput so
the body matches Execute.
2. isDateNumberFormat used a simple `strings.ContainsRune(_, 'y')` so
number formats like "JPY #,##0" (a currency prefix that happens to
contain a lone 'Y') were misread as date formats — round-tripping
integer cells out as ISO dates. The detector is now token-aware:
it skips quoted "...", `\\x`-escaped, and `[...]` bracket sections,
and only fires on an unescaped `yy` (a real Excel year token).
3. sheetCreateDims sized new append-mode sheets by `headerOn(s)` only,
but writeSheetData forces a header on empty append sheets when
Header == nil. Near 50000 rows / 200 cols this created the sheet one
row short and the follow-up set_cell_range bounced off the backend
ceiling. Size now matches the forced-header logic exactly.
4. tableGetTargets fallback paths (read-failure / selector mismatch on
--sheet-id) returned a target with name="" — already corrected for
--sheet-id structure-success path in 086876d2, but the structure-
failure fallback still left it empty. Use the id as the name there
too so the +table-get → +table-put round-trip never breaks on a
nameless sheet.
End-to-end verified against a real spreadsheet:
- table-get --dry-run with --sheet-name / --sheet-id both render the
selector field in the get_cell_ranges body
- A real round-trip (typed put → get) preserves dtypes + formats
* fix(sheets): bound --dataframe memory use with byte / row / column caps
readDataframeBytes used to read the whole Arrow file unbounded — a
stdin / file > 1 GiB would OOM the CLI long before the backend
per-sheet ceilings kicked in. decodeArrowToSheet then materialized
every record into [][]interface{} regardless of size.
Three caps now match the backend's per-sheet hard ceilings:
- byte cap: 256 MiB (covers worst-case 200×50000 cells × ~25 B Arrow
overhead). File path pre-Stat()s before opening; both file and stdin
paths read through io.LimitReader so an oversized input is rejected
without allocating the full payload.
- column cap: 200, checked at schema-decode time before allocating any
per-column slices.
- row cap: 50000, checked during record-batch iteration so a 1M-row
Arrow file is rejected mid-stream instead of fully decoding first.
End-to-end verified against PPE — a 257 MiB file is rejected at file-
Stat with a typed validation error before any read happens.
* fix(drive): wrap +export ctx cancellation/deadline as typed errs.NetworkError
The poll loop in RunExport returned ctx.Err() directly in two places —
on the inter-attempt sleep cancel and on the pre-attempt deadline check.
That let context.Canceled / context.DeadlineExceeded escape as untyped
errors at the cobra layer, bypassing the typed-error contract every
other failure path already honors.
Add wrapExportContextErr that maps both into errs.NewNetworkError with
SubtypeNetworkTransport / SubtypeNetworkTimeout respectively and
preserves the cause via .WithCause(err), so callers can still
errors.Is(err, context.Canceled) downstream.
CR-flagged at drive_export.go:229 / :234.
* ci(license): narrow Apache Arrow workaround with a follow-up assertion
The dependency-license check still has to --ignore Apache Arrow wholesale
because go-licenses' classifier parses its LICENSE.txt as a single license
and mis-reports the module as LicenseRef-C-Ares / Unknown (Arrow inlines
the c-ares 3rdparty notice alongside its own Apache-2.0). Re-classifying
on our side isn't possible without changing go-licenses itself.
The CR concern was that --ignore is too wide — a future Arrow re-license
or new inlined dep would silently sail through. Add a follow-up step that
re-checks Arrow's LICENSE.txt independently: it must still open with
"Apache License" AND must still inline the c-ares 3rdparty notice (the
two facts that make the --ignore safe today). If either invariant breaks,
CI fails here and forces a human to re-evaluate the ignore.
Verified locally — both assertions pass against the current pinned
Arrow v17.
* sync(sheets): pick up +table-put payload-shape doc corrections from spec
Mirror of the sheet-skill-spec change that fixes three places teaching
an invalid +table-put payload shape — the typed protocol only has
columns / data / dtypes / formats (no formula field) and must always
be wrapped in an outer {"sheets":[...]} envelope. write-cells and the
SKILL.md decision table previously used the wrong field names (type /
format) and pointed users at +table-put for formula writes, which the
shortcut can't actually accept.
Synced from upstream canonical via generate:cli + sync:cli.
* test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create
AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E
for new flows. Three new files cover the four shortcuts this branch
adds or materially changes:
- sheets_gridline_dryrun_test.go — pins +sheet-show-gridline /
+sheet-hide-gridline as a single modify_workbook_structure call with
the right operation name (show_gridline / hide_gridline) and
sheet_id, so an op-name typo would trip CI before any live run.
- sheets_workbook_import_dryrun_test.go — pins +workbook-import as a
two-step plan (drive media upload + drive import-task create) with
the doc type hard-coded to "sheet" — the wrapper's whole reason for
existing on top of generic drive +import. --name reaches file_name
on the wire; file_extension is sniffed from the local file.
- sheets_table_put_typed_workflow_test.go — two live workflows running
against a freshly created spreadsheet. The first runs the full
typed +table-put → +table-get round-trip (date / numeric / object
columns with custom number_format) and asserts the dtype + format
contract holds end-to-end. The second exercises the typed
+workbook-create --sheets path: create + write in one shortcut, the
payload sheet name adopts the workbook's default sheet (no empty
"Sheet1" left behind), and the typed contract still survives the
read-back.
End-to-end verified locally (user identity): typed put round-trips
preserve dtypes (date → datetime64[ns], numeric → float64, object →
object) + formats verbatim; workbook-create adopts the named sheet as
the first sheet with the same typed shape intact.
* sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec
Mirror of the sheet-skill-spec change that adds a DataFrame ↔ JSON
bridge as a skill-bundled Python script instead of inside the CLI
binary. Per PR #1355 review (docx NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, §4.2):
keep the CLI a thin JSON/REST client; pandas / Arrow editing lives in
the caller's Python process. Synced from canonical via generate:cli +
sync:cli.
- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
one sheet, .parquet / .feather / .arrow / .csv / .json. Shells out to
`+table-put` / `+table-get` over typed JSON — no CLI changes.
- SKILL.md decision tree + write-cells.md +table-put section: explicit
pointers so pandas users land on the script instead of hand-rolling
the `--sheets` payload.
End-to-end verified against PPE: 3-row DataFrame (datetime / float /
object) round-trips parquet → script put → real sheet → script get →
parquet with dtypes preserved.
* Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"
This reverts commit 2964983b92.
* sync(sheets): pick up sheets_df.py + doc DRY cleanup from spec
Mirror of the sheet-skill-spec change that ships a 32-line helper-only
sheets_df.py (df_to_sheet + sheet_to_df) and removes the corresponding
inline `def` blocks from three reference docs.
- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
one +table-put / +table-get sheet, importable as a library. Same
helper pair the docs already taught, lifted out of the prose so
callers can `from sheets_df import df_to_sheet, sheet_to_df`.
- lark-sheets-write-cells.md / lark-sheets-read-data.md /
lark-sheets-workbook.md: drop the inline helper definitions; keep
the usage examples (single/multi-sheet, round-trip) and switch them
to import-from-script. workbook reference's +workbook-create
--sheets section now points pandas users at the helper directly
(was previously a textual reference back to write-cells).
End-to-end verified against PPE (--as user):
- +workbook-create with df_to_sheet for three sheets (income / balance
/ cashflow): create ok, dtypes (datetime64[ns] / float64) + formats
(#,##0 / 0.0% / yyyy-mm-dd) survive on read-back through sheet_to_df.
- read → pandas mutate → write-back round-trip preserves both data
and formats.
* chore: drop accidentally-committed __pycache__/ and gitignore .pyc
The previous commit (5fac9c39) shipped sheets_df.py and inadvertently
included its `__pycache__/sheets_df.cpython-312.pyc` — local Python
import created the bytecode cache during PPE round-trip verification and
`git add skills/lark-sheets/` swept it in.
Remove the pyc and add Python bytecode patterns to .gitignore so the
skill-bundled helper scripts don't pull cache files into future commits.
* refactor(sheets): drop --dataframe / --dataframe-out + apache/arrow dep
Per the design review at NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, the Arrow IPC binary
input/output channel adds a heavy columnar runtime to the CLI for no new
capability — the typed JSON --sheets path already covers everything, and
the column-major / zero-copy advantages collapse the moment the CLI re-
encodes into the row-oriented sheets OpenAPI JSON body. Removing it also
lets us drop the `--ignore github.com/apache/arrow/go/v17` license-check
escape hatch.
Deleted:
- shortcuts/sheets/lark_sheet_dataframe.go (+ test)
- --dataframe branches in +table-put / +workbook-create
- --dataframe-out branch in +table-get
- StdinConsumed / MarkStdinConsumed exported methods (the binary stdin
reader was the only out-of-band consumer); internal stdinConsumed
guard against duplicate `-` input flags stays
- apache/arrow/go/v17 + transitive deps via `go mod tidy`
- CI go-licenses --ignore for arrow and the LICENSE.txt assertion step
- --dataframe / --dataframe-out coverage in skill references
Pandas users keep the round-trip via the existing skill script
skills/lark-sheets/scripts/sheets_df.py over the JSON path.
The full pre-removal state is preserved on branch feat/sheets-arrow-stash.
Upstream sheet-skill-spec follow-up: the two flag rows in the canonical
spec + base table tblV2F6fqIjyCFQW must also be dropped so the next sync
does not re-add them.
* sync(sheets): pick up --sheets one-liner fix from spec
Mirrors sheet-skill-spec 5562f83. The +table-put / +workbook-create
--sheets flag descriptions (and the --print-schema description on the
sheets array) now point at the existing df_to_sheet helper instead of
the previous misleading one-liner that produced a dict missing the
outer {"sheets":[...]} envelope and the per-sheet `name`. Agents that
copy-paste the description verbatim now build a valid payload.
Auto-synced via spec's generate:cli + sync:consumers; go generate
./shortcuts/sheets/... regenerated flag_defs_gen.go so its embedded
flagDefs stays byte-equal to data/flag-defs.json.
* test(sheets/e2e): close E2E coverage gaps for newly added shortcuts
AGENTS.md requires both dry-run and live E2E for every newly registered
shortcut, and behavior-changing refactors need at least the matching
half. Three gaps remained on feat/lark-sheets-develop:
- +sheet-show-gridline / +sheet-hide-gridline (new): only dry-run E2E.
Add sheets_gridline_workflow_test.go — create a real spreadsheet,
toggle hide then show against a live sub-sheet, assert ok=true on
both (gridline state is write-only — there is no read-back field on
+sheet-info / +workbook-info — so a successful envelope is the
meaningful signal; the dry-run E2E already pins the wire shape).
- +workbook-import (new): only dry-run E2E. Add
sheets_workbook_import_workflow_test.go — write a local CSV, run
the full upload → create-task → poll, assert ready=true with a
sheet token, +info confirms the imported workbook is reachable,
cleanup deletes the spreadsheet.
- +workbook-export refactor (no-download default changed): had live
E2E but no dry-run E2E in tests/cli_e2e/. Add
sheets_workbook_export_dryrun_test.go — pin the three sheet-
specific differences vs drive +export: type=sheet hard-coded,
csv mode routes --sheet-id onto sub_id (xlsx mode omits it), and
--output-path maps onto the dry-run plan's top-level output_dir.
Also pins the csv-without-sheet-id validation error.
* refactor(sheets): unify workbookCreatedButFillFailed with OutPartialFailure
Three "made it halfway and stopped" exits in the sheets domain previously
disagreed on shape, which made the post-failure recovery flow hard for
agents to predict from one command to another:
- +table-put partial write → exit 1, stdout ok:false envelope
- +table-put zero-sheet write → exit 1, stderr api/server_error
- +workbook-create create-but-fill → exit 2, stderr validation/failed_precondition
OutPartialFailure exists exactly for "the side effect landed but the
follow-up didn't" — it stamps an ok:false result envelope on stdout
(carrying the state the caller needs to recover) and returns the bare
partial-failure exit signal. The workbook-create fill-failure path was
the odd one out: it surfaced as a typed failed_precondition error on
stderr, which agents couldn't tell apart from a plain validation refusal
even though the spreadsheet really did exist and a retry / cleanup was
possible.
Migrate workbookCreatedButFillFailed onto OutPartialFailure so the four
call sites in +workbook-create's Execute (sheet-resolve failure, initial
fill failure, style-only resolve failure, style-only apply failure) emit
the same envelope shape +table-put's partial write does:
{
"ok": false,
"data": {
"spreadsheet_token": "shtNEW",
"reason": "spreadsheet shtNEW created but initial fill failed",
"hint": "the spreadsheet exists; retry the fill … or delete it",
"cause": {"category": "...", "subtype": "...", "message": "..."}
}
}
The inner failure's typed problem (category / subtype / message) is
flattened into the `cause` field so agents stay diagnosable from the JSON
envelope alone, instead of having to errors.Unwrap a Go error.
Updated TestExecute_WorkbookCreate_FillFailureKeepsToken to assert the
new shape (ok:false envelope on stdout, *output.PartialFailureError exit
signal, structured cause carrying the underlying invalid_response
subtype) — preserving the original test intent (token must survive for
recovery; inner cause must stay diagnosable) under the new contract.
* chore(sheets): three review nits — WithCause + stale comment + unexport
- shortcuts/sheets/flag_schema_validate.go:106 — composite-JSON shape
validation was wrapping vErr's message into a typed sheets validation
error without preserving vErr as the typed cause; add the missing
.WithCause(vErr) so errors.Unwrap and ProblemOf still find the
underlying validator error (matches every other typed-error chain
helper in the file).
- shortcuts/sheets/lark_sheet_batch_update.go:92 — comment claimed
batchUpdateInput returns "FlagErrorf-typed errors", but FlagErrorf no
longer exists (the typed-error migration replaced it with
common.ValidationErrorf / errs.ValidationError); update the comment
to reflect what is actually returned.
- shortcuts/drive/drive_export.go:121 — drop the ValidateExport public
alias and rename to validateExport. sheets +workbook-export reuses
RunExport / PlanExportDryRun from this package but inlines its own
(sheet-specific) Validate, so there is no cross-package call site —
ValidateExport was a misleading sibling of the genuinely-shared
ValidateImport. Comment added to record the asymmetry so future
readers do not export it back.
* chore(deps): drop stale indirect bumps left by the arrow removal
The earlier --dataframe / --dataframe-out + apache/arrow/go/v17 removal
deleted the arrow consumer but left two indirect lines in go.mod pinned
to the versions arrow had pulled in:
- github.com/kr/text v0.2.0
- golang.org/x/exp v0.0.0-20240222234643-814bf88cf225
With arrow gone, larksuite/cli was the only requirer of those exact
versions; every real consumer needs lower ones (kr/pretty wants
kr/text v0.1.0; charmbracelet/huh wants x/exp …20231006; xo/terminfo
wants x/exp …20220909). Removing the two indirect lines and running
`go mod tidy` lets MVS pick the real-consumer versions and drops the
explicit indirect entries entirely — go.mod net-diff against main is
now zero for this branch.
Verified locally: go build ./...; go test ./shortcuts/sheets/...
./shortcuts/drive/... ./shortcuts/common/... ./internal/auth/...
./cmd/auth/... — all green.
---------
Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
Co-authored-by: Chenweifeng-bd <chenweifeng.1534@bytedance.com>
1571 lines
61 KiB
Go
1571 lines
61 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package sheets
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/util"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// ─── +table-put (cli-only, typed DataFrame-style import) ───────────
|
||
//
|
||
// Imports a typed, column-described table into Lark Sheets, type-faithfully:
|
||
// numbers stay numbers, dates land as real dates (serial + date number_format),
|
||
// not look-alike text. The wire protocol is deliberately a "table with column
|
||
// types" — pandas DataFrames are its most common producer, but the CLI never
|
||
// has to know about pandas.
|
||
//
|
||
// Writes into an existing spreadsheet (addressed by --url / --spreadsheet-token);
|
||
// to create a fresh workbook first, use +workbook-create, then point +table-put
|
||
// at the returned token. Multiple DataFrames → multiple sheets in one call: the
|
||
// top-level `sheets` array carries one entry per sub-sheet, each matched to an
|
||
// existing sub-sheet by name (created when absent).
|
||
//
|
||
// Date faithfulness was verified empirically (see isoDateToSerial): the only
|
||
// way to get a *real* date (ISNUMBER=TRUE, sortable / pivotable) is to write
|
||
// the Excel serial number AND set a date number_format. A date *string*, even
|
||
// with a date format, stays text — so date columns always go through the
|
||
// serial conversion below.
|
||
|
||
// TablePut is the typed table-put shortcut. It writes into an existing
|
||
// spreadsheet, composing get_workbook_structure / modify_workbook_structure /
|
||
// set_cell_range — no new backend tool, and no workbook creation (use
|
||
// +workbook-create for that, consistent with every other write shortcut).
|
||
var TablePut = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+table-put",
|
||
Description: "Write a typed table (columns with types + rows) into an existing spreadsheet; numbers and dates stay type-faithful.",
|
||
Risk: "write",
|
||
Scopes: []string{"sheets:spreadsheet:read", "sheets:spreadsheet:write_only"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+table-put"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||
return err
|
||
}
|
||
payload, err := resolveTablePayload(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// --styles is parsed (and aligned against the payload's sheets) up front
|
||
// so a malformed style item fails before any write lands — mirroring
|
||
// +workbook-create's Validate.
|
||
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
|
||
return err
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
return tablePutDryRun(runtime)
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
payload, err := resolveTablePayload(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
styles, err := parseWorkbookCreateSheetStyles(runtime, payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return tablePutWrite(ctx, runtime, token, payload, styles)
|
||
},
|
||
Tips: []string{
|
||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
|
||
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
|
||
},
|
||
}
|
||
|
||
// resolveTablePayload parses --sheets (typed JSON, multi-sheet) into the
|
||
// unified internal tablePayload. Both +table-put and +workbook-create funnel
|
||
// through here so the two entry points stay in lockstep; Validate / Execute /
|
||
// DryRun / workbookCreateData all share this one decision. Network-free.
|
||
func resolveTablePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
|
||
sheetsGiven := rctx.Changed("sheets") && strings.TrimSpace(rctx.Str("sheets")) != ""
|
||
if !sheetsGiven {
|
||
return nil, common.ValidationErrorf("--sheets is required")
|
||
}
|
||
return parseTablePutPayload(rctx)
|
||
}
|
||
|
||
// ─── protocol ─────────────────────────────────────────────────────────
|
||
|
||
type tablePayload struct {
|
||
Sheets []tableSheetSpec `json:"sheets"`
|
||
}
|
||
|
||
// tableSheetSpec is the *internal* representation a sheet is normalized into
|
||
// after parsing the wire protocol. It carries everything buildSheetMatrix and
|
||
// friends need (typed columns + format + 2D row matrix) and is what the rest of
|
||
// this file works against. The wire shape — string columns + dtypes/formats
|
||
// maps + `data` — lives in tableSheetIn and is collapsed into this struct by
|
||
// (*tableSheetIn).normalize.
|
||
type tableSheetSpec struct {
|
||
Name string
|
||
StartCell string
|
||
// Mode controls write placement: "overwrite" (default) writes a header+data
|
||
// block from start_cell; "append" writes data below the sheet's existing
|
||
// data (start_cell's row is ignored, its column is honored).
|
||
Mode string
|
||
// Header is whether to write a header row of column names. nil defaults by
|
||
// mode: true for overwrite, false for append (so appended rows don't repeat
|
||
// the header). Set explicitly to override.
|
||
Header *bool
|
||
// AllowOverwrite, when explicitly false, makes the write fail if it would
|
||
// land on a non-empty cell. nil defaults to true (overwrite).
|
||
AllowOverwrite *bool
|
||
Columns []tableColumnSpec
|
||
Rows [][]interface{}
|
||
}
|
||
|
||
type tableColumnSpec struct {
|
||
Name string
|
||
Type string
|
||
Format string
|
||
}
|
||
|
||
// tableSheetIn is the wire-level shape of one sheet in --sheets. It is
|
||
// pandas-DataFrame-shaped on purpose: `columns` is a plain string list, `data`
|
||
// is a 2D array, and the per-column type / display format are *separate*
|
||
// dtypes/formats maps keyed by column name. That gives agents a one-liner
|
||
// (`{**json.loads(df.to_json(orient="split")), "dtypes":
|
||
// df.dtypes.astype(str).to_dict()}`) and lets handwritten payloads stay flat
|
||
// rather than nest a {name, type, format} object per column.
|
||
type tableSheetIn struct {
|
||
Name string `json:"name"`
|
||
StartCell string `json:"start_cell"`
|
||
Mode string `json:"mode"`
|
||
Header *bool `json:"header"`
|
||
AllowOverwrite *bool `json:"allow_overwrite"`
|
||
Columns []string `json:"columns"`
|
||
Data [][]interface{} `json:"data"`
|
||
Dtypes map[string]string `json:"dtypes"`
|
||
Formats map[string]string `json:"formats"`
|
||
}
|
||
|
||
// dtypeToTypeFormat maps a pandas-style dtype string to the internal column
|
||
// (type, default format) pair. The mapping is deliberately permissive: a missing
|
||
// or unknown dtype falls through to string + text format (`@`) so a
|
||
// `to_json(orient="split")` payload that omits `dtypes` writes correctly as an
|
||
// all-string table. Recognized families:
|
||
// - int*/uint* (lowercase numpy + capitalized nullable pandas) → number
|
||
// - float* / Float* / complex* → number
|
||
// - bool / boolean (nullable) → bool
|
||
// - datetime* (incl. tz-aware datetime64[ns, UTC]) → date, "yyyy-mm-dd"
|
||
// - everything else (object, string, category, empty, unknown) → string, "@"
|
||
//
|
||
// Explicit `formats[col]` is layered on top of this default by normalize, so a
|
||
// user-supplied `#,##0.00` on a float64 column still wins.
|
||
func dtypeToTypeFormat(dtype string) (typ, format string) {
|
||
d := strings.TrimSpace(dtype)
|
||
if d == "" {
|
||
return "string", "@"
|
||
}
|
||
lower := strings.ToLower(d)
|
||
switch {
|
||
case strings.HasPrefix(lower, "datetime"):
|
||
return "date", "yyyy-mm-dd"
|
||
case lower == "bool" || lower == "boolean":
|
||
return "bool", ""
|
||
case isNumericDtype(lower):
|
||
return "number", ""
|
||
default:
|
||
return "string", "@"
|
||
}
|
||
}
|
||
|
||
// isNumericDtype recognizes pandas/numpy numeric dtype strings (lowercased).
|
||
// Covers numpy ints (`int8`/`int64`/...), unsigned ints (`uint*`), floats
|
||
// (`float32`/`float64`), complex, and pandas' nullable variants
|
||
// (`int64`/`uint64`/`float64` lowercased from `Int64`/`UInt64`/`Float64`).
|
||
func isNumericDtype(lower string) bool {
|
||
for _, p := range []string{"int", "uint", "float", "complex"} {
|
||
if strings.HasPrefix(lower, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// typeToDtype is the inverse used by +table-get to label each output column.
|
||
// Choices are picked to be safe under a single `df.astype(dtypes)` round-trip:
|
||
// - string → object (pandas default, no-op astype)
|
||
// - number → float64 (works for all numeric cells, including ones with NaN)
|
||
// - date → datetime64[ns] (matches the ISO strings we emit)
|
||
// - bool → bool (inferColumnType only picks bool when every cell is bool)
|
||
//
|
||
// Anything else (defensive default) maps to object.
|
||
func typeToDtype(typ string) string {
|
||
switch typ {
|
||
case "number":
|
||
return "float64"
|
||
case "date":
|
||
return "datetime64[ns]"
|
||
case "bool":
|
||
return "bool"
|
||
default:
|
||
return "object"
|
||
}
|
||
}
|
||
|
||
// decoderExpectEOF ensures the decoder has nothing left to read after a
|
||
// successful Decode. json.Decoder accepts trailing non-whitespace after the
|
||
// first JSON value (unlike json.Unmarshal), so a payload like `{...} trailing`
|
||
// would silently be treated as the leading object only. Use this after the
|
||
// first Decode to surface the trailing data as a validation error.
|
||
func decoderExpectEOF(dec *json.Decoder) error {
|
||
var trailing json.RawMessage
|
||
if err := dec.Decode(&trailing); err == nil {
|
||
return fmt.Errorf("trailing data after JSON value") //nolint:forbidigo // intermediate error; the caller wraps it into a typed --sheets/--values validation error
|
||
} else if !errors.Is(err, io.EOF) {
|
||
return fmt.Errorf("trailing data after JSON value: %w", err) //nolint:forbidigo // intermediate error; the caller wraps it into a typed --sheets/--values validation error
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
|
||
// validated payload. UseNumber keeps numeric cells as json.Number so large
|
||
// integers (order IDs, etc.) survive without precision loss or scientific
|
||
// notation. The wire shape (tableSheetIn: string columns + dtypes/formats maps
|
||
// + `data`) is normalized into the internal tableSheetSpec so the rest of the
|
||
// file (buildSheetMatrix, sheetCreateDims, …) is unaware of it. Network-free:
|
||
// safe from Validate and DryRun.
|
||
func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||
raw := strings.TrimSpace(runtime.Str("sheets"))
|
||
if raw == "" {
|
||
return nil, common.ValidationErrorf("--sheets is required")
|
||
}
|
||
dec := json.NewDecoder(strings.NewReader(raw))
|
||
dec.UseNumber()
|
||
var wire struct {
|
||
Sheets []tableSheetIn `json:"sheets"`
|
||
}
|
||
if err := dec.Decode(&wire); err != nil {
|
||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||
}
|
||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||
// would otherwise pass Validate and surface as confusing downstream errors.
|
||
if err := decoderExpectEOF(dec); err != nil {
|
||
return nil, common.ValidationErrorf("--sheets: %v", err).WithCause(err)
|
||
}
|
||
p := &tablePayload{Sheets: make([]tableSheetSpec, 0, len(wire.Sheets))}
|
||
for i := range wire.Sheets {
|
||
spec, err := wire.Sheets[i].normalize(i)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
p.Sheets = append(p.Sheets, spec)
|
||
}
|
||
if err := p.validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// normalize collapses the wire-level pandas-shaped tableSheetIn into the
|
||
// internal tableSheetSpec used by the writer. It pairs each column name with
|
||
// its dtype-derived (type, format) — with `formats[name]` overriding the
|
||
// default — and renames `data` back to the writer's `Rows`. Per-column
|
||
// validation that needs the resolved type lives in tablePayload.validate (so
|
||
// errors carry the sheet-index/name context the writer already prints).
|
||
func (in *tableSheetIn) normalize(idx int) (tableSheetSpec, error) {
|
||
spec := tableSheetSpec{
|
||
Name: in.Name,
|
||
StartCell: in.StartCell,
|
||
Mode: in.Mode,
|
||
Header: in.Header,
|
||
AllowOverwrite: in.AllowOverwrite,
|
||
Rows: in.Data,
|
||
}
|
||
seenCol := make(map[string]bool, len(in.Columns))
|
||
spec.Columns = make([]tableColumnSpec, len(in.Columns))
|
||
for j, name := range in.Columns {
|
||
trimmed := strings.TrimSpace(name)
|
||
if trimmed == "" {
|
||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: columns[%d] name is required", idx, in.Name, j)
|
||
}
|
||
if seenCol[name] {
|
||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: duplicate column name %q", idx, in.Name, name)
|
||
}
|
||
seenCol[name] = true
|
||
typ, format := dtypeToTypeFormat(in.Dtypes[name])
|
||
if f, ok := in.Formats[name]; ok {
|
||
format = strings.TrimSpace(f)
|
||
}
|
||
spec.Columns[j] = tableColumnSpec{Name: name, Type: typ, Format: format}
|
||
}
|
||
// Surface dtypes/formats entries that reference a column the sheet doesn't
|
||
// have — almost always a typo (`"foramt"`, `"营 收"` with stray spaces) and
|
||
// silently ignoring them would let the writer succeed with the wrong
|
||
// formatting. The check runs after the column list is built so we can
|
||
// compare against the canonical set.
|
||
for k := range in.Dtypes {
|
||
if !seenCol[k] {
|
||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: dtypes references unknown column %q", idx, in.Name, k)
|
||
}
|
||
}
|
||
for k := range in.Formats {
|
||
if !seenCol[k] {
|
||
return tableSheetSpec{}, common.ValidationErrorf("--sheets[%d] %q: formats references unknown column %q", idx, in.Name, k)
|
||
}
|
||
}
|
||
return spec, nil
|
||
}
|
||
|
||
func (p *tablePayload) validate() error {
|
||
if len(p.Sheets) == 0 {
|
||
return common.ValidationErrorf("--sheets: must contain at least one sheet")
|
||
}
|
||
seen := make(map[string]bool, len(p.Sheets))
|
||
for i := range p.Sheets {
|
||
s := &p.Sheets[i]
|
||
if strings.TrimSpace(s.Name) == "" {
|
||
return common.ValidationErrorf("--sheets[%d]: name is required", i)
|
||
}
|
||
if seen[s.Name] {
|
||
return common.ValidationErrorf("--sheets[%d]: duplicate sheet name %q", i, s.Name)
|
||
}
|
||
seen[s.Name] = true
|
||
if len(s.Columns) == 0 {
|
||
return common.ValidationErrorf("--sheets[%d] %q: columns must be non-empty", i, s.Name)
|
||
}
|
||
for j := range s.Columns {
|
||
c := &s.Columns[j]
|
||
// validColumnType still guards the internal Type so a future
|
||
// dtype-mapping change (or a direct test-time construction of a
|
||
// tableSheetSpec) can't silently route an unknown type into
|
||
// buildTypedCell's default branch.
|
||
if !validColumnType(c.Type) {
|
||
return common.ValidationErrorf("--sheets[%d] %q: columns[%d] %q has invalid type %q (want string/number/date/bool)",
|
||
i, s.Name, j, c.Name, c.Type)
|
||
}
|
||
}
|
||
for r := range s.Rows {
|
||
if len(s.Rows[r]) != len(s.Columns) {
|
||
return common.ValidationErrorf("--sheets[%d] %q: row %d has %d cells, want %d (column count)",
|
||
i, s.Name, r, len(s.Rows[r]), len(s.Columns))
|
||
}
|
||
// Validate each cell's value against its column type up front (pure,
|
||
// network-free): a bad date/number/bool is caught here — before any
|
||
// workbook is created — instead of failing mid-write and leaving a
|
||
// stray empty spreadsheet behind.
|
||
for c := range s.Columns {
|
||
if _, err := buildTypedCell(&s.Columns[c], s.Rows[r][c]); err != nil {
|
||
return common.ValidationErrorf("--sheets[%d] %q: row %d column %q: %v", i, s.Name, r, s.Columns[c].Name, err).WithCause(err)
|
||
}
|
||
}
|
||
}
|
||
if sc := strings.TrimSpace(s.StartCell); sc != "" {
|
||
if _, _, ok := splitCellRef(sc); !ok {
|
||
return common.ValidationErrorf("--sheets[%d] %q: start_cell %q must be a single cell ref (e.g. A1)", i, s.Name, sc)
|
||
}
|
||
}
|
||
switch s.Mode {
|
||
case "", "overwrite", "append":
|
||
default:
|
||
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validColumnType reports whether a column type is one the writer understands.
|
||
// An empty type is valid and means "type-less" (raw passthrough): the value is
|
||
// written as-is and Lark Sheets auto-detects its type — see buildTypedCell.
|
||
// +workbook-create's --values synthesizes an all-type-less payload through this.
|
||
func validColumnType(t string) bool {
|
||
switch t {
|
||
case "", "string", "number", "date", "bool":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ─── type mapping ─────────────────────────────────────────────────────
|
||
|
||
// headerOn reports whether a header row of column names should be written. A
|
||
// nil Header defaults by mode: overwrite writes it; append omits it so the
|
||
// appended rows don't repeat the header below an existing one.
|
||
func headerOn(s *tableSheetSpec) bool {
|
||
if s.Header != nil {
|
||
return *s.Header
|
||
}
|
||
return s.Mode != "append"
|
||
}
|
||
|
||
// buildSheetMatrix turns a sheet spec into the set_cell_range cells matrix:
|
||
// optionally a header row of column names, then one row per data record with
|
||
// each cell mapped by its column type. Per-column number_format is attached so
|
||
// numbers/dates render correctly (and dates become real dates). Header cells
|
||
// carry no style of their own — style them via --styles like any other cell.
|
||
func buildSheetMatrix(s *tableSheetSpec, writeHeader bool) ([][]interface{}, error) {
|
||
ncols := len(s.Columns)
|
||
matrix := make([][]interface{}, 0, len(s.Rows)+1)
|
||
|
||
if writeHeader {
|
||
header := make([]interface{}, ncols)
|
||
for c := range s.Columns {
|
||
header[c] = map[string]interface{}{"value": s.Columns[c].Name}
|
||
}
|
||
matrix = append(matrix, header)
|
||
}
|
||
|
||
for r := range s.Rows {
|
||
row := make([]interface{}, ncols)
|
||
for c := range s.Columns {
|
||
cell, err := buildTypedCell(&s.Columns[c], s.Rows[r][c])
|
||
if err != nil {
|
||
return nil, common.ValidationErrorf("sheet %q row %d column %q: %v", s.Name, r, s.Columns[c].Name, err).WithCause(err)
|
||
}
|
||
row[c] = cell
|
||
}
|
||
matrix = append(matrix, row)
|
||
}
|
||
return matrix, nil
|
||
}
|
||
|
||
// buildTypedCell maps one raw JSON value to a set_cell_range cell per its
|
||
// declared column type. A nil (JSON null) becomes an empty cell that still
|
||
// carries the column's number_format. number values are kept as json.Number to
|
||
// preserve precision; dates are converted to Excel serials. A type-less column
|
||
// (Type == "") writes the raw scalar unchanged, letting the backend auto-detect
|
||
// the type — the untyped --values path relies on this.
|
||
func buildTypedCell(col *tableColumnSpec, raw interface{}) (map[string]interface{}, error) {
|
||
cell := map[string]interface{}{}
|
||
nf := strings.TrimSpace(col.Format)
|
||
if nf == "" {
|
||
switch col.Type {
|
||
case "date":
|
||
nf = "yyyy-mm-dd"
|
||
case "string":
|
||
// Text format keeps digit-like strings (IDs / postcodes / phone numbers)
|
||
// as text, and lets +table-get infer the column back as string instead
|
||
// of guessing number from a numeric-looking value.
|
||
nf = "@"
|
||
}
|
||
}
|
||
if nf != "" {
|
||
cell["cell_styles"] = map[string]interface{}{"number_format": nf}
|
||
}
|
||
if raw == nil {
|
||
return cell, nil
|
||
}
|
||
switch col.Type {
|
||
case "":
|
||
// Type-less column: write the raw JSON scalar as-is so Lark Sheets
|
||
// auto-detects the type (numeric → number, else text). json.Number is
|
||
// kept verbatim for precision; an optional --styles number_format
|
||
// controls display. This is the untyped --values behavior.
|
||
cell["value"] = raw
|
||
case "string":
|
||
cell["value"] = stringifyCellValue(raw)
|
||
case "number":
|
||
n, ok := raw.(json.Number)
|
||
if !ok {
|
||
return nil, fmt.Errorf("number expects a numeric value, got %s", describeJSONType(raw)) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||
}
|
||
cell["value"] = n
|
||
case "bool":
|
||
b, ok := raw.(bool)
|
||
if !ok {
|
||
return nil, fmt.Errorf("bool expects true/false, got %s", describeJSONType(raw)) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||
}
|
||
cell["value"] = b
|
||
case "date":
|
||
str, ok := raw.(string)
|
||
if !ok {
|
||
return nil, fmt.Errorf("date expects an ISO yyyy-mm-dd string, got %s", describeJSONType(raw)) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||
}
|
||
serial, err := isoDateToSerial(str)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cell["value"] = serial
|
||
default:
|
||
return nil, fmt.Errorf("unsupported type %q", col.Type) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||
}
|
||
return cell, nil
|
||
}
|
||
|
||
// stringifyCellValue renders any JSON scalar as the literal text a string
|
||
// column should hold. json.Number keeps its exact digits (no scientific
|
||
// notation), so IDs / postcodes survive as written.
|
||
func stringifyCellValue(raw interface{}) string {
|
||
switch v := raw.(type) {
|
||
case string:
|
||
return v
|
||
case json.Number:
|
||
return v.String()
|
||
case bool:
|
||
if v {
|
||
return "TRUE"
|
||
}
|
||
return "FALSE"
|
||
default:
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
}
|
||
|
||
func describeJSONType(raw interface{}) string {
|
||
switch raw.(type) {
|
||
case string:
|
||
return "a string"
|
||
case json.Number:
|
||
return "a number"
|
||
case bool:
|
||
return "a boolean"
|
||
case []interface{}:
|
||
return "an array"
|
||
case map[string]interface{}:
|
||
return "an object"
|
||
default:
|
||
return fmt.Sprintf("%T", raw)
|
||
}
|
||
}
|
||
|
||
// excelEpoch is the Excel / Lark Sheets serial-date origin (1899-12-30 = 0).
|
||
// Verified empirically: writing serial 45306 renders as 2024-01-15 in Lark
|
||
// Sheets, matching Excel's 1900 date system exactly.
|
||
var excelEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
|
||
|
||
// isoDateToSerial converts an ISO yyyy-mm-dd string to its Excel serial day
|
||
// number. The result is written as a numeric cell value with a date
|
||
// number_format, which is the only combination that yields a real (sortable,
|
||
// pivotable, ISNUMBER=TRUE) date in Lark Sheets.
|
||
//
|
||
// Accepts both bare dates (`2024-01-15`) and full ISO datetime strings with a
|
||
// `T` separator (`2024-01-15T00:00:00.000`, `2024-01-15T08:30:00+08:00`). The
|
||
// `T...` suffix is dropped before parsing so the pandas `df_to_sheet` helper
|
||
// — which uses `df.to_json(orient="split", date_format="iso")` and therefore
|
||
// always emits the full ISO form — round-trips without an extra string clean
|
||
// step on the agent side. A leading `T` (no date prefix) is left alone so the
|
||
// parser still rejects it cleanly.
|
||
func isoDateToSerial(s string) (int, error) {
|
||
s = strings.TrimSpace(s)
|
||
if i := strings.Index(s, "T"); i > 0 {
|
||
s = s[:i]
|
||
}
|
||
t, err := time.Parse("2006-01-02", s)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("date %q must be ISO yyyy-mm-dd: %w", s, err) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||
}
|
||
return int(math.Round(t.Sub(excelEpoch).Hours() / 24)), nil
|
||
}
|
||
|
||
// ─── range helpers ────────────────────────────────────────────────────
|
||
|
||
// tablePutMaxCellsPerWrite caps a single set_cell_range write. Larger
|
||
// sheets are split into row batches so one oversized request can't exceed the
|
||
// tool's cell ceiling. Matches +cells-set's default --max-cells.
|
||
const tablePutMaxCellsPerWrite = 50000
|
||
|
||
// sheetAnchor returns the resolved start cell (default A1) and its 0-based
|
||
// column/row. Caller has already validated start_cell, so the parse can't fail
|
||
// in practice; the ok guard is defensive.
|
||
func sheetAnchor(s *tableSheetSpec) (anchor string, col0, row0 int, err error) {
|
||
anchor = strings.TrimSpace(s.StartCell)
|
||
if anchor == "" {
|
||
anchor = "A1"
|
||
}
|
||
c, r, ok := splitCellRef(anchor)
|
||
if !ok {
|
||
return "", 0, 0, common.ValidationErrorf("start_cell %q must be a single cell ref (e.g. A1)", anchor)
|
||
}
|
||
return anchor, c, r, nil
|
||
}
|
||
|
||
// tablePutFullRange is the A1 rectangle the whole matrix (header + data)
|
||
// occupies, for reporting in the result / dry-run. Returns "" when there is
|
||
// nothing to write (e.g. header=false with no data rows) — the previous
|
||
// formula would have produced an invalid trailing row like "A1:C0".
|
||
func tablePutFullRange(s *tableSheetSpec, totalRows int) string {
|
||
if totalRows <= 0 || len(s.Columns) == 0 {
|
||
return ""
|
||
}
|
||
_, col0, row0, err := sheetAnchor(s)
|
||
if err != nil {
|
||
return strings.TrimSpace(s.StartCell)
|
||
}
|
||
ncols := len(s.Columns)
|
||
return fmt.Sprintf("%s%d:%s%d",
|
||
columnIndexToLetter(col0), row0+1,
|
||
columnIndexToLetter(col0+ncols-1), row0+totalRows)
|
||
}
|
||
|
||
// ─── write path ───────────────────────────────────────────────────────
|
||
|
||
// writeSheetData writes one sheet's matrix via set_cell_range, splitting into
|
||
// row batches when the cell count would exceed tablePutMaxCellsPerWrite.
|
||
// Returns a per-sheet summary for the result envelope.
|
||
func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, s *tableSheetSpec, styles *workbookCreateStylePayload, dims gridDims) (map[string]interface{}, error) {
|
||
_, col0, row0, err := sheetAnchor(s)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ncols := len(s.Columns)
|
||
|
||
// append mode starts below the sheet's existing data; start_cell's row is
|
||
// ignored (its column is still honored). overwrite mode anchors at row0.
|
||
baseRow := row0
|
||
writeHeader := headerOn(s)
|
||
if s.Mode == "append" {
|
||
lastRow, err := lastDataRow(ctx, runtime, token, sheetID, dims)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("resolving last data row for append: %w", err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
if lastRow > 0 {
|
||
baseRow = lastRow // 0-based index of the row just below the 1-based last data row
|
||
} else if s.Header == nil {
|
||
// appending to an empty sheet with no explicit header choice: write the
|
||
// header so column names aren't lost (and a later +table-get doesn't
|
||
// consume the first data row as the header).
|
||
writeHeader = true
|
||
}
|
||
}
|
||
|
||
matrix, err := buildSheetMatrix(s, writeHeader)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := applyWorkbookCreateStylesToMatrix(matrix, styles, col0, baseRow, fmt.Sprintf("--styles for sheet %q", s.Name)); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(matrix) == 0 {
|
||
// header:false with no data rows — nothing to write.
|
||
return map[string]interface{}{
|
||
"name": s.Name, "sheet_id": sheetID, "range": "",
|
||
"data_rows": 0, "columns": ncols, "writes": 0, "mode": writeModeName(s),
|
||
}, nil
|
||
}
|
||
|
||
startCol := columnIndexToLetter(col0)
|
||
endCol := columnIndexToLetter(col0 + ncols - 1)
|
||
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
|
||
|
||
rowsPerBatch := tablePutMaxCellsPerWrite / ncols
|
||
if rowsPerBatch < 1 {
|
||
rowsPerBatch = 1
|
||
}
|
||
|
||
writes := 0
|
||
for start := 0; start < len(matrix); start += rowsPerBatch {
|
||
end := start + rowsPerBatch
|
||
if end > len(matrix) {
|
||
end = len(matrix)
|
||
}
|
||
batchRange := fmt.Sprintf("%s%d:%s%d", startCol, baseRow+start+1, endCol, baseRow+end)
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"sheet_id": sheetID,
|
||
"range": batchRange,
|
||
"cells": matrix[start:end],
|
||
}
|
||
if !allowOverwrite {
|
||
input["allow_overwrite"] = false
|
||
}
|
||
if _, err := callTool(ctx, runtime, token, ToolKindWrite, "set_cell_range", input); err != nil {
|
||
return nil, fmt.Errorf("writing rows %d-%d: %w", start+1, end, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
writes++
|
||
}
|
||
if err := applyWorkbookCreateVisualOps(ctx, runtime, token, sheetID, styles); err != nil {
|
||
return nil, fmt.Errorf("applying visual styles: %w", err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
return map[string]interface{}{
|
||
"name": s.Name,
|
||
"sheet_id": sheetID,
|
||
"range": fmt.Sprintf("%s%d:%s%d", startCol, baseRow+1, endCol, baseRow+len(matrix)),
|
||
"data_rows": len(s.Rows),
|
||
"columns": ncols,
|
||
"writes": writes,
|
||
"mode": writeModeName(s),
|
||
}, nil
|
||
}
|
||
|
||
// writeModeName normalizes the sheet's write mode to a non-empty label for
|
||
// result / dry-run reporting ("" defaults to "overwrite").
|
||
func writeModeName(s *tableSheetSpec) string {
|
||
if s.Mode == "append" {
|
||
return "append"
|
||
}
|
||
return "overwrite"
|
||
}
|
||
|
||
// lastDataRow returns the 1-based row number of the last row containing data in
|
||
// the sheet (0 when empty), so append mode can place new rows just below it. It
|
||
// reads current_region via get_range_as_csv — the backend's reported true data
|
||
// extent.
|
||
//
|
||
// The anchor range matters for the same reason it does in sheetCurrentRegion:
|
||
// current_region is the bounding box of non-empty cells WITHIN the requested
|
||
// range, so an A1 anchor stops at the first fully-empty row and reports a
|
||
// too-small last row — append would then write on top of and overwrite the data
|
||
// past that gap. Anchoring over the whole grid (A1:<lastCol><lastRow>, from the
|
||
// sheet's row_count / column_count) makes the probe span internal blank rows.
|
||
// When dimensions are unknown it falls back to the legacy A1 anchor.
|
||
func lastDataRow(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, dims gridDims) (int, error) {
|
||
anchor := "A1"
|
||
if dims.rows > 0 && dims.cols > 0 {
|
||
anchor = "A1:" + columnIndexToLetter(dims.cols-1) + strconv.Itoa(dims.rows)
|
||
}
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", map[string]interface{}{
|
||
"excel_id": token,
|
||
"sheet_id": sheetID,
|
||
"range": anchor,
|
||
"max_rows": unboundedReadLimit,
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
m, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return 0, nil // empty sheet — no output
|
||
}
|
||
region, _ := m["current_region"].(string)
|
||
if region == "" {
|
||
region, _ = m["actual_range"].(string)
|
||
}
|
||
return a1EndRow(region), nil
|
||
}
|
||
|
||
// writeTypedSheets writes a typed payload's sheets into a workbook and returns
|
||
// the per-sheet summaries. It deliberately does not emit output — the caller
|
||
// composes the envelope, because +table-put and +workbook-create report
|
||
// different top-level shapes (a bare token vs. the full spreadsheet metadata).
|
||
// Existing sub-sheets are matched by name in a single structure read; missing
|
||
// ones are created on demand.
|
||
//
|
||
// adoptSheetID, when non-empty, is the id of a freshly created workbook's
|
||
// default sub-sheet: the first payload sheet adopts it (the default sheet is
|
||
// renamed to that sheet's name and reused) so the new workbook isn't left with
|
||
// a stray empty "Sheet1" beside the typed sheets. +table-put passes "" (it
|
||
// writes into an existing workbook, with no default sheet to adopt);
|
||
// +workbook-create passes the default sheet's id.
|
||
//
|
||
// On failure it returns the summaries written so far alongside the error, so
|
||
// the caller can surface a partial_success.
|
||
func writeTypedSheets(ctx context.Context, runtime *common.RuntimeContext, token string, payload *tablePayload, adoptSheetID string, styles *workbookCreateSheetStyles) ([]interface{}, error) {
|
||
byName, dimsByName, err := listSheetIDsByName(ctx, runtime, token)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Adopt the default sheet as the first payload sheet (rename + reuse), so a
|
||
// just-created workbook doesn't keep its empty default sheet around. Skip if
|
||
// a sheet by that name already exists (it'll be matched normally below).
|
||
if adoptSheetID != "" && len(payload.Sheets) > 0 {
|
||
first := payload.Sheets[0].Name
|
||
if _, exists := byName[first]; !exists {
|
||
if err := renameSheet(ctx, runtime, token, adoptSheetID, first); err != nil {
|
||
return nil, fmt.Errorf("adopting the default sheet as %q failed: %w", first, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
byName[first] = adoptSheetID
|
||
}
|
||
}
|
||
|
||
written := make([]interface{}, 0, len(payload.Sheets))
|
||
for i := range payload.Sheets {
|
||
s := &payload.Sheets[i]
|
||
sheetID, ok := byName[s.Name]
|
||
if !ok {
|
||
rows, cols := sheetCreateDims(s)
|
||
sheetID, err = createSheet(ctx, runtime, token, s.Name, rows, cols)
|
||
if err != nil {
|
||
return written, fmt.Errorf("creating sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
byName[s.Name] = sheetID
|
||
// A freshly created sheet's grid is exactly what we just asked for.
|
||
dimsByName[s.Name] = gridDims{rows: rows, cols: cols}
|
||
}
|
||
summary, err := writeSheetData(ctx, runtime, token, sheetID, s, styles.styleFor(i), dimsByName[s.Name])
|
||
if err != nil {
|
||
return written, fmt.Errorf("writing sheet %q failed: %w", s.Name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
written = append(written, summary)
|
||
}
|
||
return written, nil
|
||
}
|
||
|
||
// renameSheet renames a sub-sheet in place via modify_workbook_structure. Used
|
||
// to adopt a freshly created workbook's default sheet as the first typed sheet
|
||
// (see writeTypedSheets); mirrors +sheet-rename's tool input.
|
||
func renameSheet(ctx context.Context, runtime *common.RuntimeContext, token, sheetID, newName string) error {
|
||
_, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "rename",
|
||
"sheet_id": sheetID,
|
||
"new_name": newName,
|
||
})
|
||
return err
|
||
}
|
||
|
||
// tablePutWrite writes the payload into an existing workbook and emits the
|
||
// +table-put envelope. The shared write loop lives in writeTypedSheets; this
|
||
// wrapper adds +table-put's output shape and partial-success reporting. styles
|
||
// (optional, parsed from --styles) is forwarded so per-sheet visual ops apply
|
||
// in the same call.
|
||
func tablePutWrite(ctx context.Context, runtime *common.RuntimeContext, token string, payload *tablePayload, styles *workbookCreateSheetStyles) error {
|
||
written, err := writeTypedSheets(ctx, runtime, token, payload, "", styles)
|
||
if err != nil {
|
||
return tablePutPartial(runtime, token, nil, written, err.Error())
|
||
}
|
||
runtime.Out(map[string]interface{}{
|
||
"spreadsheet_token": token,
|
||
"sheets": written,
|
||
}, nil)
|
||
return nil
|
||
}
|
||
|
||
// createSheet appends a new sub-sheet sized to hold the spec, then resolves its
|
||
// id. The backend's default sheet (20 cols × 200 rows) is too small for wide or
|
||
// long tables (e.g. a 37-column quarter matrix), so the create request sizes the
|
||
// sheet to the write range up front — otherwise the follow-up set_cell_range
|
||
// fails with "range … exceeds sheet bounds". modify_workbook_structure's create
|
||
// output shape isn't relied upon — the id is read back by name, which is robust
|
||
// across tool-response variations.
|
||
func createSheet(ctx context.Context, runtime *common.RuntimeContext, token, name string, rows, cols int) (string, error) {
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"operation": "create",
|
||
"sheet_name": name,
|
||
}
|
||
if rows > 0 {
|
||
input["rows"] = rows
|
||
}
|
||
if cols > 0 {
|
||
input["columns"] = cols
|
||
}
|
||
if _, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_workbook_structure", input); err != nil {
|
||
return "", err
|
||
}
|
||
id, _, err := lookupSheetIndex(ctx, runtime, token, "", name)
|
||
if err != nil {
|
||
return "", fmt.Errorf("sheet %q created but resolving its id failed: %w", name, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// sheetCreateDims sizes a to-be-created sheet to the spec's write range so the
|
||
// follow-up set_cell_range can't exceed sheet bounds. It accounts for the
|
||
// start_cell offset and the optional header row. The backend's 20×200 defaults
|
||
// are kept as floors (ordinary small tables are created exactly as before) and
|
||
// its hard limits (200 cols, 50000 rows) as ceilings.
|
||
func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
|
||
_, col0, row0, _ := sheetAnchor(s)
|
||
cols = col0 + len(s.Columns)
|
||
rows = row0 + len(s.Rows)
|
||
// Match writeSheetData's header decision exactly. headerOn() is false for
|
||
// append mode by default, but writeSheetData *forces* a header when append
|
||
// hits an empty sheet with no explicit Header choice (so column names
|
||
// aren't lost on the first append). sheetCreateDims runs only when the
|
||
// sheet is being created — therefore the sheet IS empty and that forced
|
||
// header WILL be written, so size for it here. Without this the sheet is
|
||
// created one row short and append-near-50000 / append-at-N-cols-200
|
||
// would bounce off the backend's hard cap.
|
||
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
|
||
rows++
|
||
}
|
||
if cols < 20 {
|
||
cols = 20
|
||
}
|
||
if cols > 200 {
|
||
cols = 200
|
||
}
|
||
if rows < 200 {
|
||
rows = 200
|
||
}
|
||
if rows > 50000 {
|
||
rows = 50000
|
||
}
|
||
return rows, cols
|
||
}
|
||
|
||
// gridDims is a sub-sheet's physical grid size (row_count × column_count from
|
||
// get_workbook_structure). A zero in either field means "unknown", which makes
|
||
// the used-range probes (sheetCurrentRegion / lastDataRow) fall back to their
|
||
// legacy A1 anchor instead of a full-grid one.
|
||
type gridDims struct {
|
||
rows int
|
||
cols int
|
||
}
|
||
|
||
// listSheetIDsByName maps every existing sub-sheet's display name to its id and
|
||
// physical grid dimensions via a single get_workbook_structure read. Used by
|
||
// write mode to decide which payload sheets already exist (and, for append, to
|
||
// anchor the last-data-row probe over the whole grid — see lastDataRow).
|
||
func listSheetIDsByName(ctx context.Context, runtime *common.RuntimeContext, token string) (map[string]string, map[string]gridDims, error) {
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{
|
||
"excel_id": token,
|
||
})
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
m, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return nil, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned non-object output")
|
||
}
|
||
sheets, _ := m["sheets"].([]interface{})
|
||
byName := make(map[string]string, len(sheets))
|
||
dimsByName := make(map[string]gridDims, len(sheets))
|
||
for _, raw := range sheets {
|
||
id, name, rc, cc := tableGetSheetMeta(raw)
|
||
if id != "" && name != "" {
|
||
byName[name] = id
|
||
dimsByName[name] = gridDims{rows: rc, cols: cc}
|
||
}
|
||
}
|
||
return byName, dimsByName, nil
|
||
}
|
||
|
||
// tablePutPartial reports a multi-sheet write that failed partway. When some
|
||
// sheets already landed it is a partial_success: their summaries are the primary
|
||
// machine-readable output, so we emit an ok:false result envelope on stdout (via
|
||
// OutPartialFailure) carrying written_sheets, and return the partial-failure exit
|
||
// signal — callers can retry the rest or delete the workbook. When nothing landed
|
||
// — the first or only sheet failed — it is a plain failure, so we return a typed
|
||
// errs.APIError rather than misleadingly claiming "some sheets were written".
|
||
func tablePutPartial(runtime *common.RuntimeContext, token string, spreadsheet interface{}, written []interface{}, reason string) error {
|
||
if len(written) == 0 {
|
||
return errs.NewAPIError(errs.SubtypeServerError, "table-put failed on %s: %s", token, reason).
|
||
WithHint("no sheets were written; fix the cause and retry")
|
||
}
|
||
data := map[string]interface{}{
|
||
"spreadsheet_token": token,
|
||
"written_sheets": written,
|
||
"reason": reason,
|
||
"hint": "some sheets were written; inspect written_sheets, then retry the remaining sheets or delete the spreadsheet",
|
||
}
|
||
if spreadsheet != nil {
|
||
data["spreadsheet"] = spreadsheet
|
||
}
|
||
return runtime.OutPartialFailure(data, nil)
|
||
}
|
||
|
||
// ─── dry-run ──────────────────────────────────────────────────────────
|
||
|
||
// tablePutDryRun renders the set_cell_range write the shortcut would send for
|
||
// each sheet, plus any --styles visual ops (cell_styles merged into the matrix;
|
||
// merges / row+col sizes as their own tool calls). Network-free; the payload,
|
||
// locator, and styles have already been validated by Validate, so errors here
|
||
// degrade to an empty preview rather than twice.
|
||
func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
dry := common.NewDryRunAPI()
|
||
payload, err := resolveTablePayload(runtime)
|
||
if err != nil {
|
||
return dry
|
||
}
|
||
token, err := resolveSpreadsheetToken(runtime)
|
||
if err != nil {
|
||
return dry
|
||
}
|
||
sheetStyles, _ := parseWorkbookCreateSheetStyles(runtime, payload)
|
||
for i := range payload.Sheets {
|
||
s := &payload.Sheets[i]
|
||
matrix, _ := buildSheetMatrix(s, headerOn(s))
|
||
desc := fmt.Sprintf("write sheet %q (%d data rows × %d cols, mode=%s) via set_cell_range",
|
||
s.Name, len(s.Rows), len(s.Columns), writeModeName(s))
|
||
rng := tablePutFullRange(s, len(matrix))
|
||
if s.Mode == "append" {
|
||
rng = "<append below existing data>"
|
||
} else {
|
||
// cell_styles are merged into the matrix only for overwrite mode,
|
||
// where the anchor row is known statically; append's base row is
|
||
// resolved at execute time, so the preview leaves the matrix bare
|
||
// (the merges / sizes ops below still render).
|
||
_, col0, row0, _ := sheetAnchor(s)
|
||
_ = applyWorkbookCreateStylesToMatrix(matrix, sheetStyles.styleFor(i), col0, row0, fmt.Sprintf("--styles for sheet %q", s.Name))
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"sheet_name": s.Name,
|
||
"range": rng,
|
||
"cells": matrix,
|
||
}
|
||
if s.AllowOverwrite != nil && !*s.AllowOverwrite {
|
||
input["allow_overwrite"] = false
|
||
}
|
||
wireBody, _ := buildToolBody("set_cell_range", input)
|
||
dry.POST(toolInvokePath(token, ToolKindWrite)).Desc(desc).Body(wireBody)
|
||
appendWorkbookCreateVisualOpsDryRun(dry, token, "", s.Name, sheetStyles.styleFor(i))
|
||
}
|
||
return dry
|
||
}
|
||
|
||
// ─── +table-get (typed read-back, mirror of +table-put) ───────────────
|
||
//
|
||
// Reads a spreadsheet's sheets back into the same typed protocol +table-put
|
||
// consumes, so the output round-trips: pipe it straight back to +table-put, or
|
||
// load it into a DataFrame. Column types are inferred from each column's
|
||
// number_format (a date format → date, numeric/percent → number) and date
|
||
// serials are converted back to ISO strings — the exact inverse of the put path.
|
||
|
||
// TableGet reads sheets back into the typed table protocol.
|
||
var TableGet = common.Shortcut{
|
||
Service: "sheets",
|
||
Command: "+table-get",
|
||
Description: "Read sheets back into the typed table protocol (mirror of +table-put); column types are inferred from number_format so the output feeds straight to +table-put or a DataFrame.",
|
||
Risk: "read",
|
||
Scopes: []string{"sheets:spreadsheet:read"},
|
||
AuthTypes: []string{"user", "bot"},
|
||
HasFormat: true,
|
||
Flags: flagsFor("+table-get"),
|
||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
if _, err := resolveSpreadsheetToken(runtime); err != nil {
|
||
return err
|
||
}
|
||
if strings.TrimSpace(runtime.Str("sheet-id")) != "" && strings.TrimSpace(runtime.Str("sheet-name")) != "" {
|
||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive")
|
||
}
|
||
return nil
|
||
},
|
||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||
token, _ := resolveSpreadsheetToken(runtime)
|
||
dry := common.NewDryRunAPI()
|
||
// get_workbook_structure runs on every path now (not just whole-workbook):
|
||
// the single-sheet selector path also reads it to learn the grid's physical
|
||
// dimensions, which anchor the default used-range probe over the full grid.
|
||
body, _ := buildToolBody("get_workbook_structure", map[string]interface{}{"excel_id": token})
|
||
dry.POST(toolInvokePath(token, ToolKindRead)).Desc("read sub-sheets + grid dimensions via get_workbook_structure").Body(body)
|
||
rng := strings.TrimSpace(runtime.Str("range"))
|
||
if rng == "" {
|
||
rng = "<each sheet's used range (full-grid current_region)>"
|
||
}
|
||
// Mirror the selector the Execute path will pass to get_cell_ranges so the
|
||
// dry-run body matches the real request shape; agents that validate one and
|
||
// run the other would otherwise see a sheet_id/sheet_name field appear out
|
||
// of nowhere. Network-free: only echoes the flags the caller already gave.
|
||
input := map[string]interface{}{
|
||
"excel_id": token, "ranges": []string{rng},
|
||
"include_styles": true, "value_render_option": "raw_value",
|
||
}
|
||
sheetSelectorForToolInput(input,
|
||
strings.TrimSpace(runtime.Str("sheet-id")),
|
||
strings.TrimSpace(runtime.Str("sheet-name")),
|
||
)
|
||
body, _ = buildToolBody("get_cell_ranges", input)
|
||
dry.POST(toolInvokePath(token, ToolKindRead)).
|
||
Desc(fmt.Sprintf("read cells (%s) + styles via get_cell_ranges, then infer column types", rng)).
|
||
Body(body)
|
||
return dry
|
||
},
|
||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
targets, err := tableGetTargets(ctx, runtime, token)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
noHeader := runtime.Bool("no-header")
|
||
userRange := strings.TrimSpace(runtime.Str("range"))
|
||
sheets := make([]interface{}, 0, len(targets))
|
||
for _, t := range targets {
|
||
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sheets = append(sheets, spec)
|
||
}
|
||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||
return nil
|
||
},
|
||
Tips: []string{
|
||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
|
||
},
|
||
}
|
||
|
||
// tableGetSheet identifies a sheet to read back.
|
||
//
|
||
// rowCount / colCount carry the sheet's physical grid dimensions
|
||
// (get_workbook_structure's row_count / column_count). They're used to anchor
|
||
// the default used-range probe over the whole grid instead of A1 — see
|
||
// sheetCurrentRegion. 0 means "unknown" (structure read skipped or failed), in
|
||
// which case the probe falls back to the legacy A1 anchor.
|
||
type tableGetSheet struct {
|
||
id string
|
||
name string
|
||
rowCount int
|
||
colCount int
|
||
}
|
||
|
||
// tableGetTargets resolves which sheets +table-get reads: the one named by
|
||
// --sheet-id / --sheet-name, or every sheet (in workbook order) when neither is
|
||
// given. Either way it reads get_workbook_structure once so each target carries
|
||
// its physical grid dimensions (rowCount / colCount) — the default used-range
|
||
// probe needs them to anchor over the full grid. For the single-sheet selector
|
||
// path this also backfills the missing name (handy when only --sheet-id was
|
||
// given, so the output spec isn't left nameless).
|
||
func tableGetTargets(ctx context.Context, runtime *common.RuntimeContext, token string) ([]tableGetSheet, error) {
|
||
id := strings.TrimSpace(runtime.Str("sheet-id"))
|
||
name := strings.TrimSpace(runtime.Str("sheet-name"))
|
||
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_workbook_structure", map[string]interface{}{"excel_id": token})
|
||
if err != nil {
|
||
// Single-sheet selector path can degrade gracefully without dimensions
|
||
// (the probe falls back to the A1 anchor); the whole-workbook path can't
|
||
// enumerate sheets without the structure, so it must surface the error.
|
||
// Name doubles as id for --sheet-id so the output spec is never nameless
|
||
// (an empty name would break +table-get → +table-put round-trip — the
|
||
// writer requires a non-empty sheet name).
|
||
if id != "" {
|
||
return []tableGetSheet{{id: id, name: id}}, nil
|
||
}
|
||
if name != "" {
|
||
return []tableGetSheet{{name: name}}, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
m, _ := out.(map[string]interface{})
|
||
raw, _ := m["sheets"].([]interface{})
|
||
|
||
// Selector path: find the matching sheet to pick up its dimensions (and
|
||
// backfill name when only --sheet-id was given). If no row matches, fall
|
||
// back to a dimensionless target so the read still proceeds via the A1
|
||
// anchor rather than erroring on a structure/selector mismatch — same
|
||
// id-as-name backfill applies so the output spec round-trips.
|
||
if id != "" || name != "" {
|
||
for _, r := range raw {
|
||
sid, sname, rc, cc := tableGetSheetMeta(r)
|
||
if (id != "" && sid == id) || (name != "" && sname == name) {
|
||
return []tableGetSheet{{id: sid, name: sname, rowCount: rc, colCount: cc}}, nil
|
||
}
|
||
}
|
||
if id != "" {
|
||
return []tableGetSheet{{id: id, name: id}}, nil
|
||
}
|
||
return []tableGetSheet{{name: name}}, nil
|
||
}
|
||
|
||
targets := make([]tableGetSheet, 0, len(raw))
|
||
for _, r := range raw {
|
||
sid, sname, rc, cc := tableGetSheetMeta(r)
|
||
if sid != "" {
|
||
targets = append(targets, tableGetSheet{id: sid, name: sname, rowCount: rc, colCount: cc})
|
||
}
|
||
}
|
||
if len(targets) == 0 {
|
||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "get_workbook_structure returned no sheets")
|
||
}
|
||
return targets, nil
|
||
}
|
||
|
||
// tableGetSheetMeta pulls one sheet's id, name (title fallback), and physical
|
||
// grid dimensions out of a get_workbook_structure sheets[] entry. Dimensions
|
||
// default to 0 ("unknown") when absent or non-numeric.
|
||
func tableGetSheetMeta(r interface{}) (id, name string, rowCount, colCount int) {
|
||
sm, ok := r.(map[string]interface{})
|
||
if !ok {
|
||
return "", "", 0, 0
|
||
}
|
||
id, _ = sm["sheet_id"].(string)
|
||
name, _ = sm["sheet_name"].(string)
|
||
if name == "" {
|
||
name, _ = sm["title"].(string)
|
||
}
|
||
if f, ok := util.ToFloat64(sm["row_count"]); ok {
|
||
rowCount = int(f)
|
||
}
|
||
if f, ok := util.ToFloat64(sm["column_count"]); ok {
|
||
colCount = int(f)
|
||
}
|
||
return id, name, rowCount, colCount
|
||
}
|
||
|
||
// readSheetAsSpec reads one sheet's region and rebuilds it as a typed-protocol
|
||
// sheet — the inverse of the put path and the same wire shape +table-put
|
||
// accepts: a string `columns` list, a 2D `data` matrix, and `dtypes` / `formats`
|
||
// maps keyed by column name. That symmetry lets callers round-trip via the
|
||
// pandas-native idiom
|
||
//
|
||
// pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])
|
||
//
|
||
// without a custom helper. `dtypes` is always emitted (one entry per column, so
|
||
// a single `astype()` call covers every column); `formats` is emitted only for
|
||
// columns whose source cells carry a non-empty number_format, since `astype`
|
||
// ignores it and we'd rather not pollute the output.
|
||
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool) (map[string]interface{}, error) {
|
||
emptySpec := func() map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"name": t.name,
|
||
"columns": []interface{}{},
|
||
"data": []interface{}{},
|
||
"dtypes": map[string]interface{}{},
|
||
"range": "",
|
||
}
|
||
}
|
||
region := userRange
|
||
if region == "" {
|
||
r, err := sheetCurrentRegion(ctx, runtime, token, t)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
region = r
|
||
}
|
||
if region == "" {
|
||
return emptySpec(), nil // empty sheet
|
||
}
|
||
input := map[string]interface{}{
|
||
"excel_id": token,
|
||
"ranges": []string{region},
|
||
"include_styles": true,
|
||
"value_render_option": "raw_value",
|
||
"cell_limit": unboundedReadLimit,
|
||
}
|
||
sheetSelectorForToolInput(input, t.id, t.name)
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
grid := extractCellGrid(out)
|
||
if len(grid) == 0 {
|
||
return emptySpec(), nil
|
||
}
|
||
|
||
var headerRow []map[string]interface{}
|
||
dataRows := grid
|
||
if !noHeader {
|
||
headerRow = grid[0]
|
||
dataRows = grid[1:]
|
||
}
|
||
ncols := 0
|
||
for _, r := range grid {
|
||
if len(r) > ncols {
|
||
ncols = len(r)
|
||
}
|
||
}
|
||
|
||
columnNames := make([]interface{}, ncols)
|
||
colTypes := make([]string, ncols)
|
||
dtypes := make(map[string]interface{}, ncols)
|
||
formats := map[string]interface{}{}
|
||
// Duplicate header names break +table-get → +table-put round-trip: the dtypes
|
||
// map (keyed by name) silently collapses to a single entry and the writer
|
||
// later rejects the duplicate columns in --sheets validation. Fail fast with
|
||
// an actionable hint when the source sheet actually has duplicate headers.
|
||
// noHeader mode is exempt because tableGetColumnName falls back to positional
|
||
// col<N> names which are always unique.
|
||
seenNames := map[string]int{}
|
||
for c := 0; c < ncols; c++ {
|
||
typ, format := inferColumnType(dataRows, c)
|
||
colTypes[c] = typ
|
||
name := tableGetColumnName(headerRow, c, noHeader)
|
||
if !noHeader {
|
||
if prev, dup := seenNames[name]; dup {
|
||
return nil, common.ValidationErrorf(
|
||
"sheet %q: duplicate header column name %q at columns %d and %d; this would break the +table-get → +table-put round-trip. Rename the headers or pass --no-header to read by position (col1/col2/…).",
|
||
t.name, name, prev+1, c+1)
|
||
}
|
||
seenNames[name] = c
|
||
}
|
||
columnNames[c] = name
|
||
dtypes[name] = typeToDtype(typ)
|
||
// Only emit a format when the column actually has one and it's not the
|
||
// implicit text-format we paint on string columns (the `@` is a writer
|
||
// convention, not user intent — surfacing it would round-trip back as
|
||
// an explicit format the user never set).
|
||
if format != "" && !isTextNumberFormat(format) {
|
||
formats[name] = format
|
||
}
|
||
}
|
||
|
||
data := make([][]interface{}, 0, len(dataRows))
|
||
for _, r := range dataRows {
|
||
row := make([]interface{}, ncols)
|
||
for c := 0; c < ncols; c++ {
|
||
row[c] = cellToTyped(cellAt(r, c), colTypes[c])
|
||
}
|
||
data = append(data, row)
|
||
}
|
||
spec := map[string]interface{}{
|
||
"name": t.name,
|
||
"columns": columnNames,
|
||
"data": data,
|
||
"dtypes": dtypes,
|
||
// The range actually read — whether from --range or the computed used
|
||
// range. get_cell_ranges has no has_more flag, so this is the only signal
|
||
// a caller has to detect truncation (compare its extent against the source
|
||
// xlsx / +workbook-info). Harmless on round-trip: +table-put ignores it.
|
||
"range": region,
|
||
}
|
||
if len(formats) > 0 {
|
||
spec["formats"] = formats
|
||
}
|
||
return spec, nil
|
||
}
|
||
|
||
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
|
||
// or "" for an empty sheet.
|
||
//
|
||
// It reads get_range_as_csv's current_region, but the anchor range it requests
|
||
// is critical: current_region is the bounding box of the non-empty cells WITHIN
|
||
// the requested range, so an A1 anchor stops at the first fully-empty row or
|
||
// column and silently truncates everything past it (the pro016 / pro025
|
||
// incident). Anchoring over the whole physical grid (A1:<lastCol><lastRow>,
|
||
// from the sheet's row_count / column_count) instead makes the backend span
|
||
// internal gaps and report the true used range. When dimensions are unknown
|
||
// (structure read skipped / failed) it falls back to the legacy A1 anchor so
|
||
// the path degrades to its prior behavior rather than erroring.
|
||
func sheetCurrentRegion(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet) (string, error) {
|
||
anchor := "A1"
|
||
if t.rowCount > 0 && t.colCount > 0 {
|
||
anchor = "A1:" + columnIndexToLetter(t.colCount-1) + strconv.Itoa(t.rowCount)
|
||
}
|
||
input := map[string]interface{}{"excel_id": token, "range": anchor, "max_rows": unboundedReadLimit}
|
||
sheetSelectorForToolInput(input, t.id, t.name)
|
||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_range_as_csv", input)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
m, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return "", nil
|
||
}
|
||
region, _ := m["current_region"].(string)
|
||
if region == "" {
|
||
region, _ = m["actual_range"].(string)
|
||
}
|
||
return region, nil
|
||
}
|
||
|
||
// extractCellGrid pulls ranges[0].cells out of a get_cell_ranges response into a
|
||
// 2D grid of cell objects (each carrying value + cell_styles).
|
||
func extractCellGrid(out interface{}) [][]map[string]interface{} {
|
||
m, ok := out.(map[string]interface{})
|
||
if !ok {
|
||
return nil
|
||
}
|
||
ranges, _ := m["ranges"].([]interface{})
|
||
if len(ranges) == 0 {
|
||
return nil
|
||
}
|
||
r0, _ := ranges[0].(map[string]interface{})
|
||
rawCells, _ := r0["cells"].([]interface{})
|
||
grid := make([][]map[string]interface{}, 0, len(rawCells))
|
||
for _, rr := range rawCells {
|
||
rowArr, _ := rr.([]interface{})
|
||
row := make([]map[string]interface{}, 0, len(rowArr))
|
||
for _, cc := range rowArr {
|
||
cm, _ := cc.(map[string]interface{})
|
||
row = append(row, cm)
|
||
}
|
||
grid = append(grid, row)
|
||
}
|
||
return grid
|
||
}
|
||
|
||
func cellAt(row []map[string]interface{}, c int) map[string]interface{} {
|
||
if c >= 0 && c < len(row) {
|
||
return row[c]
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func readCellValue(cell map[string]interface{}) interface{} {
|
||
if cell == nil {
|
||
return nil
|
||
}
|
||
return cell["value"]
|
||
}
|
||
|
||
func readCellFormat(cell map[string]interface{}) string {
|
||
if cell == nil {
|
||
return ""
|
||
}
|
||
st, _ := cell["cell_styles"].(map[string]interface{})
|
||
nf, _ := st["number_format"].(string)
|
||
return nf
|
||
}
|
||
|
||
// inferColumnType decides a column's type from its data cells: a date
|
||
// number_format guides each cell's type, but a column is given a non-string type
|
||
// only when EVERY non-empty cell agrees. Real sheet columns often mix types (a
|
||
// number column with a stray "暂无", a date column with a bare count); declaring
|
||
// number/date while a string value rides along makes the output inconsistent —
|
||
// it breaks round-trip back into +table-put (which rejects a string in a number
|
||
// column) and crashes pandas astype. So a mixed column degrades to string
|
||
// (lossless, self-consistent), keeping columns[].type faithful to every value in
|
||
// rows. Coercing dirty cells onto a numeric column is deliberately left to the
|
||
// caller (pandas to_numeric(errors="coerce") on the string column): lossless
|
||
// there — the original values stay in the frame — whereas doing it here would
|
||
// drop them silently and irrecoverably.
|
||
func inferColumnType(dataRows [][]map[string]interface{}, c int) (string, string) {
|
||
seen := map[string]bool{}
|
||
numberFormat, dateFormat := "", ""
|
||
for _, r := range dataRows {
|
||
cell := cellAt(r, c)
|
||
v := readCellValue(cell)
|
||
if v == nil {
|
||
continue
|
||
}
|
||
if s, ok := v.(string); ok && s == "" {
|
||
continue // empty string is empty, not a string value
|
||
}
|
||
nf := readCellFormat(cell)
|
||
switch {
|
||
case isDateNumberFormat(nf):
|
||
// A date format yields date only when the cell is actually a serial
|
||
// number; a date format painted on text is just text.
|
||
if _, ok := tableGetToFloat(v); ok {
|
||
seen["date"] = true
|
||
if dateFormat == "" {
|
||
dateFormat = nf
|
||
}
|
||
} else {
|
||
seen["string"] = true
|
||
}
|
||
case isTextNumberFormat(nf):
|
||
seen["string"] = true
|
||
default:
|
||
switch v.(type) {
|
||
case float64, json.Number:
|
||
seen["number"] = true
|
||
if numberFormat == "" {
|
||
numberFormat = nf
|
||
}
|
||
case bool:
|
||
seen["bool"] = true
|
||
default:
|
||
seen["string"] = true
|
||
}
|
||
}
|
||
}
|
||
switch {
|
||
case len(seen) == 0:
|
||
return "string", "" // all empty
|
||
case len(seen) == 1:
|
||
switch {
|
||
case seen["date"]:
|
||
return "date", dateFormat
|
||
case seen["number"]:
|
||
return "number", numberFormat
|
||
case seen["bool"]:
|
||
return "bool", ""
|
||
default:
|
||
return "string", ""
|
||
}
|
||
default:
|
||
return "string", "" // mixed types → string (self-consistent, lossless)
|
||
}
|
||
}
|
||
|
||
// isDateNumberFormat reports whether a number_format denotes a date/time. Date
|
||
// formats carry a year token (Excel's 'yy' or 'yyyy'); pure numeric formats
|
||
// (#,##0, 0.00, 0.00%, @) do not.
|
||
//
|
||
// Token-aware so currency / unit prefixes that happen to contain a lone 'y' or
|
||
// 'Y' — most notably "JPY #,##0" — are not misread as dates. The scanner skips:
|
||
// - characters inside double-quoted literals ("Yen ")
|
||
// - the character following a backslash escape (\y)
|
||
// - characters inside [...] sections ([Red], [$EUR-2])
|
||
//
|
||
// and only fires on an unquoted/unescaped/unbracketed 'yy' (a single 'y' is
|
||
// not a year token in Excel; "JPY 0" has 'Y' but never 'yy').
|
||
func isDateNumberFormat(nf string) bool {
|
||
s := strings.ToLower(nf)
|
||
inQuote, inBracket, escape := false, false, false
|
||
for i := 0; i < len(s); i++ {
|
||
c := s[i]
|
||
if escape {
|
||
escape = false
|
||
continue
|
||
}
|
||
switch {
|
||
case c == '\\':
|
||
escape = true
|
||
case !inBracket && c == '"':
|
||
inQuote = !inQuote
|
||
case !inQuote && c == '[':
|
||
inBracket = true
|
||
case !inQuote && c == ']':
|
||
inBracket = false
|
||
case !inQuote && !inBracket:
|
||
if c == 'y' && i+1 < len(s) && s[i+1] == 'y' {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isTextNumberFormat reports whether a number_format is Excel/Lark text format
|
||
// ("@"), which +table-put writes on string columns so digit-like strings survive
|
||
// and read back as string instead of being inferred as number.
|
||
func isTextNumberFormat(nf string) bool {
|
||
return strings.TrimSpace(nf) == "@"
|
||
}
|
||
|
||
// cellToTyped converts a read-back cell to the JSON-safe value its column type
|
||
// implies: date serials become ISO strings, numbers/bools pass through, empty
|
||
// cells become null, everything else is stringified. inferColumnType guarantees
|
||
// a non-string column is homogeneous, so the date/number branches never meet a
|
||
// stray off-type value.
|
||
func cellToTyped(cell map[string]interface{}, typ string) interface{} {
|
||
v := readCellValue(cell)
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
if s, ok := v.(string); ok && s == "" {
|
||
return nil
|
||
}
|
||
switch typ {
|
||
case "date":
|
||
if f, ok := tableGetToFloat(v); ok {
|
||
return serialToISO(f)
|
||
}
|
||
return v
|
||
case "number", "bool":
|
||
return v
|
||
default:
|
||
return stringifyCellValue(v)
|
||
}
|
||
}
|
||
|
||
// tableGetColumnName returns the column's name: the header cell's text, or a
|
||
// positional col<N> when there is no header row.
|
||
func tableGetColumnName(headerRow []map[string]interface{}, c int, noHeader bool) string {
|
||
if !noHeader {
|
||
if v := readCellValue(cellAt(headerRow, c)); v != nil {
|
||
if s := stringifyCellValue(v); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return fmt.Sprintf("col%d", c+1)
|
||
}
|
||
|
||
func tableGetToFloat(v interface{}) (float64, bool) {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return n, true
|
||
case json.Number:
|
||
f, err := n.Float64()
|
||
return f, err == nil
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// serialToISO converts an Excel serial day number back to an ISO yyyy-mm-dd
|
||
// string — the inverse of isoDateToSerial.
|
||
func serialToISO(serial float64) string {
|
||
return excelEpoch.AddDate(0, 0, int(serial)).Format("2006-01-02")
|
||
}
|