Compare commits

..

86 Commits

Author SHA1 Message Date
xiongyuanwen-byted
c7a3dd472e 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.
2026-06-24 16:56:32 +08:00
xiongyuanwen-byted
5fac9c39a5 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.
2026-06-24 16:55:26 +08:00
xiongyuanwen-byted
14cb134cac Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"
This reverts commit 2964983b92.
2026-06-24 16:33:56 +08:00
xiongyuanwen-byted
2964983b92 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.
2026-06-24 16:23:30 +08:00
xiongyuanwen-byted
ca8bf48851 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.
2026-06-24 16:03:21 +08:00
xiongyuanwen-byted
79362a8fe8 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.
2026-06-24 16:03:04 +08:00
xiongyuanwen-byted
e57381ae5c 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.
2026-06-24 16:02:54 +08:00
xiongyuanwen-byted
5a5c1f430b 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.
2026-06-24 16:02:42 +08:00
xiongyuanwen-byted
aa69572803 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.
2026-06-24 16:02:31 +08:00
xiongyuanwen-byted
46faf36201 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
2026-06-24 16:02:18 +08:00
xiongyuanwen-byted
47a3c1c66f 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.
2026-06-24 15:06:41 +08:00
xiongyuanwen-byted
b276e92f6b 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.
2026-06-24 14:58:02 +08:00
xiongyuanwen-byted
a07b178b9b 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.
2026-06-24 13:35:03 +08:00
xiongyuanwen-byted
e9c4b1e151 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.
2026-06-24 13:29:24 +08:00
xiongyuanwen-byted
9ef0d370bd 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(...).
2026-06-24 13:29:17 +08:00
xiongyuanwen-byted
1aa3305f5a 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.
2026-06-24 13:10:51 +08:00
xiongyuanwen-byted
086876d272 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.
2026-06-24 13:10:39 +08:00
xiongyuanwen-byted
d994c27819 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).
2026-06-24 13:10:25 +08:00
xiongyuanwen-byted
d2517180a4 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.
2026-06-24 12:44:59 +08:00
xiongyuanwen-byted
e1b7826646 Merge branch 'feat/lark-sheets-develop' of https://github.com/larksuite/cli into feat/lark-sheets-develop 2026-06-24 11:54:34 +08:00
zhengzhijiej-tech
3171b61493 Merge pull request #1556 from larksuite/fix/sheets-range-move-wiki
fix(sheets): resolve wiki URL in +range-move/+range-copy Execute
2026-06-24 11:43:02 +08:00
xiongyuanwen-byted
7ad8945f10 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-24 11:41:07 +08:00
zhengzhijie
9f32f8461b 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.
2026-06-24 11:39:43 +08:00
xiongyuanwen-byted
5e0770421f 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.
2026-06-24 11:07:26 +08:00
xiongyuanwen-byted
a125bffbaa 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).
2026-06-24 11:07:18 +08:00
zhengzhijiej-tech
68f867d6a5 Merge pull request #1519 from larksuite/feat/sheets-wiki-url
feat(sheets): resolve wiki URLs to the backing spreadsheet for --url
2026-06-23 11:06:36 +08:00
zhengzhijie
78f7fba89e 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.
2026-06-22 19:13:38 +08:00
zhengzhijie
06241666a0 docs(sheets): note --url accepts wiki URLs (synced from spec) 2026-06-22 19:13:07 +08:00
zhengzhijie
a35cc26131 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).
2026-06-22 19:13:07 +08:00
xiongyuanwen-byted
b6da950be3 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 提示。
2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
aa545083b6 docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段) 2026-06-22 18:28:05 +08:00
xiongyuanwen-byted
5c7100ee4c 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)
2026-06-22 12:29:03 +08:00
xiongyuanwen-byted
3ef3a9d1d3 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-22 10:14:25 +08:00
xiongyuanwen-byted
bdad336caf docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导 2026-06-20 14:11:02 +08:00
xiongyuanwen-byted
39a7d4bfb4 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 零改动
2026-06-20 14:11:02 +08:00
xiongyuanwen-byted
4b404fc0ee docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引
源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。
2026-06-18 15:58:20 +08:00
xiongyuanwen-byted
fc6e1e25de docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签
源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。
2026-06-18 13:07:30 +08:00
xiongyuanwen-byted
14d3107bf2 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.
2026-06-17 21:24:54 +08:00
zhengzhijiej-tech
e795f4f068 Merge pull request #1482 from larksuite/zzj/mention-doc-link
feat(sheets): document link requirement for @document mentions
2026-06-17 14:12:41 +08:00
xiongyuanwen-byted
2e4033a1a0 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.
2026-06-17 11:35:37 +08:00
xiongyuanwen-byted
fc44564b01 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.
2026-06-16 20:47:54 +08:00
xiongyuanwen-byted
7742a47072 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.
2026-06-16 20:10:47 +08:00
xiongyuanwen-byted
3668b904ca Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-16 20:01:50 +08:00
xiongyuanwen-byted
1c68d31d12 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/drive/drive_export.go
#	shortcuts/drive/drive_import.go
2026-06-16 19:52:50 +08:00
xiongyuanwen-byted
4c51cd36fb 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.
2026-06-16 18:48:00 +08:00
xiongyuanwen-byted
bbeae3636c 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.
2026-06-16 18:48:00 +08:00
zhengzhijiej-tech
a9d88c5666 Merge pull request #1486 from larksuite/fix/cond-format-attrs-shape-validation
fix(sheets): reject cond-format attrs whose shape mismatches rule_type
2026-06-16 17:49:32 +08:00
zhengzhijie
4801675fd6 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.
2026-06-16 17:45:11 +08:00
zhengzhijie
dd04b3705f 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.
2026-06-16 17:23:58 +08:00
zhengzhijie
439f184ba5 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.
2026-06-16 14:58:36 +08:00
xiongyuanwen-byted
825071fd7a 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.
2026-06-16 14:25:19 +08:00
xiongyuanwen-byted
72999cd303 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).
2026-06-16 12:56:59 +08:00
xiongyuanwen-byted
f9c73e217d 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.
2026-06-16 10:55:23 +08:00
xiongyuanwen-byted
5f3c1c8e6a 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.
2026-06-15 18:46:34 +08:00
zhengzhijiej-tech
ead8aa854f Merge pull request #1439 from larksuite/fix/sheet-mention-type-enum
fix(sheets): add mention_type enum to set_cell_range cells schema
2026-06-15 11:50:35 +08:00
xiongyuanwen-byted
833b7cde33 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	shortcuts/sheets/lark_sheet_workbook.go
#	shortcuts/sheets/lark_sheet_workbook_test.go
2026-06-15 11:26:15 +08:00
xiongyuanwen-byted
57d71607e1 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.
2026-06-14 22:40:39 +08:00
xiongyuanwen-byted
d2c326a78c 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.
2026-06-12 17:32:08 +08:00
zhengzhijie
422797305a 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)
2026-06-12 16:53:40 +08:00
xiongyuanwen-byted
3fa28c10fa Merge remote-tracking branch 'origin/feat/lark-sheets-develop' into feat/lark-sheets-develop 2026-06-12 12:03:00 +08:00
xiongyuanwen-byted
27d185c91c 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)
2026-06-12 12:02:32 +08:00
zhengzhijiej-tech
83926943ae Merge pull request #1397 from larksuite/fix-chart-aggregate-counta-zzj
feat(sheets): add counta to chart aggregateType enum
2026-06-11 19:11:36 +08:00
zhengzhijie
752bfcbbb9 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.
2026-06-11 16:45:28 +08:00
zhengzhijie
80d9f6b59b 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.
2026-06-11 14:32:03 +08:00
xiongyuanwen-byted
080ef44cdb Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 19:52:08 +08:00
xiongyuanwen-byted
f046fb6282 fix(sheets): regenerate flag defs and fix asasalint in table io 2026-06-09 17:48:58 +08:00
xiongyuanwen-byted
ca9eddb142 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-06-09 17:29:26 +08:00
zhengzhijiej-tech
1caeb2d377 Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a66bef66af 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).
2026-06-09 16:25:02 +08:00
zhengzhijie
421805d35c 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.
2026-06-09 16:25:02 +08:00
zhengzhijie
8d5bb73c70 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.
2026-06-09 15:34:05 +08:00
xiongyuanwen-byted
97b9ffb466 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.
2026-06-08 20:38:10 +08:00
zhengzhijiej-tech
336f147ca6 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
zhengzhijie
0a47f35c7d 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.
2026-06-08 19:07:44 +08:00
Chenweifeng-bd
72ac526e23 docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
zhengzhijie
023a8786f0 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.
2026-06-08 15:44:35 +08:00
zhengzhijie
3ecd75b53d 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
2026-06-08 14:38:00 +08:00
xiongyuanwen-byted
5bf71428a4 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.
2026-06-08 12:58:11 +08:00
xiongyuanwen-byted
e819e819fe 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.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
2017e9dab8 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.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
74a02e6f2d 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.
2026-06-07 22:39:58 +08:00
xiongyuanwen-byted
02f4f73227 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).
2026-06-07 00:30:13 +08:00
xiongyuanwen-byted
a2625d036d 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)
2026-06-05 20:03:33 +08:00
zhengzhijie
d005694e0f 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
2026-06-05 19:20:25 +08:00
zhengzhijiej-tech
3149c77134 Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
zhengzhijie
6e067f2180 feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts 2026-06-04 17:00:07 +08:00
95 changed files with 1862 additions and 6657 deletions

View File

@@ -318,7 +318,39 @@ jobs:
continue-on-error: true
run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./...
- name: Check dependency licenses
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown
# --ignore github.com/apache/arrow/go/v17: Arrow is Apache-2.0 overall,
# but its LICENSE.txt also inlines the c-ares 3rdparty notice (Arrow's
# python wheels statically link c-ares) — and go-licenses' classifier
# parses the whole file as a single license, so it reports the module
# as "LicenseRef-C-Ares / Unknown". The follow-up step pins the actual
# license type by inspecting the LICENSE.txt itself, so the wholesale
# --ignore here doesn't become a free pass for future Arrow re-licensing.
# Required by sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC ingest).
run: go run github.com/google/go-licenses/v2@v2.0.1 check ./... --disallowed_types=forbidden,restricted,reciprocal,unknown --ignore github.com/apache/arrow/go/v17
- name: Assert Apache Arrow LICENSE.txt remains Apache-2.0
# Independent re-check that the go-licenses ignore above is purely a
# classifier workaround, not a free pass: confirm Arrow's LICENSE.txt
# still opens with the Apache License and still inlines the c-ares
# notice that is the actual reason go-licenses misreports the module.
# If Arrow ever re-licenses its primary license or drops the c-ares
# notice (meaning go-licenses might start reporting a different /
# genuinely problematic identifier instead), this step fails and a
# human must re-evaluate the --ignore.
run: |
set -euo pipefail
LICENSE_PATH="$(go env GOMODCACHE)/github.com/apache/arrow/go/v17@v17.0.0/LICENSE.txt"
if [ ! -f "$LICENSE_PATH" ]; then
echo "::error::Apache Arrow LICENSE.txt not found at $LICENSE_PATH" >&2
exit 1
fi
if ! head -50 "$LICENSE_PATH" | grep -q 'Apache License' ; then
echo "::error::Apache Arrow LICENSE.txt no longer leads with the Apache License — re-evaluate the go-licenses --ignore" >&2
exit 1
fi
if ! grep -q '3rdparty dependency c-ares' "$LICENSE_PATH" ; then
echo "::error::Apache Arrow LICENSE.txt no longer inlines the c-ares notice — go-licenses may now report a different identifier; re-evaluate the --ignore" >&2
exit 1
fi
license-header:
if: ${{ github.event_name == 'pull_request' }}

View File

@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
}
var foundTask bool
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
}

View File

@@ -96,34 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -1,132 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
type CardActionTriggerOutput struct {
Type string `json:"type" desc:"Event type; always card.action.trigger"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
}
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Operator struct {
OpenID string `json:"open_id"`
} `json:"operator"`
Token string `json:"token"`
Host string `json:"host"`
Action struct {
Tag string `json:"tag"`
Value map[string]interface{} `json:"value"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Option string `json:"option"`
Options []string `json:"options"`
Checked bool `json:"checked"`
Timezone string `json:"timezone"`
} `json:"action"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
formValue := marshalToString(envelope.Event.Action.FormValue)
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,
Host: envelope.Event.Host,
Token: envelope.Event.Token,
ActionTag: envelope.Event.Action.Tag,
ActionValue: actionValue,
ActionName: envelope.Event.Action.Name,
FormValue: formValue,
InputValue: envelope.Event.Action.InputValue,
Option: envelope.Event.Action.Option,
Options: options,
Checked: envelope.Event.Action.Checked,
Timezone: envelope.Event.Action.Timezone,
}
if out.MessageID != "" && rt != nil {
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
}
return json.Marshal(out)
}
// fetchCardUserDSL gets the card message content via message get API.
// Returns empty string on any failure — never blocks event consumption.
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
resp, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
return ""
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
Body struct {
Content string `json:"content"`
} `json:"body"`
} `json:"items"`
} `json:"data"`
}
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
return ""
}
return result.Data.Items[0].Body.Content
}
func marshalToString(m map[string]interface{}) string {
if len(m) == 0 {
return ""
}
b, _ := json.Marshal(m)
return string(b)
}

View File

@@ -1,432 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("card.action.trigger must set Schema.Custom")
}
if def.Process == nil {
t.Error("card.action.trigger must set Process")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty")
}
}
func TestProcessCardAction_Button(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_btn_001",
"event_type": "card.action.trigger",
"create_time": "1776409469273"
},
"event": {
"operator": {"open_id": "ou_operator"},
"token": "c-token-btn",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "approve"},
"name": "approve_btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_msg_001",
"open_chat_id": "oc_chat_001"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Type != "card.action.trigger" {
t.Errorf("Type = %q, want card.action.trigger", out.Type)
}
if out.EventID != "ev_btn_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.OperatorID != "ou_operator" {
t.Errorf("OperatorID = %q", out.OperatorID)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
if out.ActionValue != `{"key":"approve"}` {
t.Errorf("ActionValue = %q", out.ActionValue)
}
if out.ActionName != "approve_btn" {
t.Errorf("ActionName = %q", out.ActionName)
}
if out.Token != "c-token-btn" {
t.Errorf("Token = %q", out.Token)
}
if out.MessageID != "om_msg_001" {
t.Errorf("MessageID = %q", out.MessageID)
}
if out.ChatID != "oc_chat_001" {
t.Errorf("ChatID = %q", out.ChatID)
}
if out.Host != "im_message" {
t.Errorf("Host = %q", out.Host)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessCardAction_FormSubmit(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_form_001",
"event_type": "card.action.trigger",
"create_time": "1776409469274"
},
"event": {
"operator": {"open_id": "ou_form_user"},
"token": "c-token-form",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "submit_btn",
"form_value": {"name": "test-user", "reason": "testing"},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_form_001",
"open_chat_id": "oc_chat_002"
}
}
}`
out := runCardAction(t, payload, nil)
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
t.Errorf("FormValue = %q", out.FormValue)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
}
func TestProcessCardAction_MultiSelect(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_ms_001",
"event_type": "card.action.trigger",
"create_time": "1776409469275"
},
"event": {
"operator": {"open_id": "ou_ms_user"},
"token": "c-token-ms",
"host": "im_message",
"action": {
"tag": "multi_select_static",
"value": {},
"name": "multi_select",
"options": ["opt_1", "opt_3"],
"checked": false
},
"context": {
"open_message_id": "om_ms_001",
"open_chat_id": "oc_chat_003"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Options != "opt_1,opt_3" {
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
}
if out.ActionTag != "multi_select_static" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_Input(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_input_001",
"event_type": "card.action.trigger",
"create_time": "1776409469276"
},
"event": {
"operator": {"open_id": "ou_input_user"},
"token": "c-token-input",
"host": "im_message",
"action": {
"tag": "input",
"value": {},
"name": "text_input",
"input_value": "hello world",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_input_001",
"open_chat_id": "oc_chat_004"
}
}
}`
out := runCardAction(t, payload, nil)
if out.InputValue != "hello world" {
t.Errorf("InputValue = %q", out.InputValue)
}
if out.ActionTag != "input" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_DatePicker(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_date_001",
"event_type": "card.action.trigger",
"create_time": "1776409469277"
},
"event": {
"operator": {"open_id": "ou_date_user"},
"token": "c-token-date",
"host": "im_message",
"action": {
"tag": "date_picker",
"value": {},
"name": "date_selector",
"option": "2024-04-01 +0800",
"timezone": "Asia/Shanghai",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_date_001",
"open_chat_id": "oc_chat_005"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Option != "2024-04-01 +0800" {
t.Errorf("Option = %q", out.Option)
}
if out.Timezone != "Asia/Shanghai" {
t.Errorf("Timezone = %q", out.Timezone)
}
}
func TestProcessCardAction_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "card.action.trigger",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ok",
"event_type": "card.action.trigger",
"create_time": "1776409469278"
},
"event": {
"operator": {"open_id": "ou_mg_user"},
"token": "c-token-mg",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "click"},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_001",
"open_chat_id": "oc_chat_mg"
}
}
}`
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
mock := &mockAPIClient{resp: `{
"code": 0,
"msg": "success",
"data": {
"items": [{
"body": {"content": "` + escapeJSON(cardContent) + `"}
}]
}
}`}
out := runCardAction(t, payload, mock)
if out.CardContent == "" {
t.Error("CardContent should not be empty when message get succeeds")
}
}
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ec",
"event_type": "card.action.trigger",
"create_time": "1776409469279"
},
"event": {
"operator": {"open_id": "ou_mg_user2"},
"token": "c-token-mg2",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_002",
"open_chat_id": "oc_chat_mg2"
}
}
}`
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
}
}
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_fail",
"event_type": "card.action.trigger",
"create_time": "1776409469280"
},
"event": {
"operator": {"open_id": "ou_mg_user3"},
"token": "c-token-mg3",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_003",
"open_chat_id": "oc_chat_mg3"
}
}
}`
mock := &mockAPIClient{errResp: true}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
}
}
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_no_msg",
"event_type": "card.action.trigger",
"create_time": "1776409469281"
},
"event": {
"operator": {"open_id": "ou_no_msg"},
"token": "c-token-nm",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "",
"open_chat_id": "oc_chat_nm"
}
}
}`
out := runCardAction(t, payload, nil)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
}
}
type mockAPIClient struct {
resp string
errResp bool
}
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if m.errResp {
return nil, context.DeadlineExceeded
}
return json.RawMessage(m.resp), nil
}
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "card.action.trigger",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out CardActionTriggerOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -27,21 +27,6 @@ func Keys() []event.KeyDefinition {
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
{
Key: "card.action.trigger",
DisplayName: "Card action",
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
EventType: "card.action.trigger",
SubscriptionType: event.SubTypeCallback,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
},
Process: processCardAction,
Scopes: []string{"im:message:readonly"},
AuthTypes: []string{"bot"},
SingleConsumer: true,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
for _, rk := range nativeIMKeys {

View File

@@ -7,7 +7,6 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -18,7 +17,6 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

View File

@@ -1,32 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -1,119 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

12
go.mod
View File

@@ -27,6 +27,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/apache/arrow/go/v17 v17.0.0
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@@ -42,13 +44,17 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/flatbuffers v24.3.25+incompatible // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
@@ -57,10 +63,16 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
)

32
go.sum
View File

@@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -52,12 +54,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
@@ -74,11 +80,16 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -97,6 +108,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -133,14 +146,20 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -156,6 +175,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
@@ -169,10 +189,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -131,3 +131,31 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -29,31 +29,3 @@ func checkOwnerUID(path, label string) error {
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -5,22 +5,7 @@
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets-getter.cmd")
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
t.Fatalf("write temp command: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "exec provider command",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}

View File

@@ -1,545 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
nextStepRecordList = "use +record-list to list records in the resolved table"
titleResolveQueryMaxLen = 30
)
var BaseURLResolve = common.Shortcut{
Service: "base",
Command: "+url-resolve",
Description: "Resolve a Base-related URL into Base coordinates",
Risk: "read",
Scopes: []string{},
ConditionalScopes: []string{
"base:field:read",
"base:record:read",
"wiki:node:retrieve",
},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readURLResolveInput(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
raw, err := readURLResolveInput(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
parsed, err := parseResolveURL(raw)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
switch classifyBaseURL(parsed) {
case "wiki_url":
return common.NewDryRunAPI().
GET("/open-apis/wiki/v2/spaces/get_node").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
case "record_share_url":
return common.NewDryRunAPI().
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
default:
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseURLResolve(runtime)
},
}
var BaseTitleResolve = common.Shortcut{
Service: "base",
Command: "+title-resolve",
Description: "Resolve a Base title or keyword through Drive search",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readTitleResolveQuery(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
query, err := readTitleResolveQuery(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/search/v2/doc_wiki/search").
Body(buildTitleResolveSearchBody(query))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseTitleResolve(runtime)
},
}
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
urlValue := strings.TrimSpace(runtime.Str("url"))
queryValue := strings.TrimSpace(runtime.Str("query"))
if urlValue != "" && queryValue != "" {
return "", baseFlagErrorf("--url and --query are mutually exclusive")
}
value := urlValue
if value == "" {
value = queryValue
}
if value == "" {
return "", baseFlagErrorf("specify --url")
}
return value, nil
}
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
values := []struct {
name string
value string
}{
{"title", strings.TrimSpace(runtime.Str("title"))},
{"query", strings.TrimSpace(runtime.Str("query"))},
{"url", strings.TrimSpace(runtime.Str("url"))},
}
var pickedName, pickedValue string
for _, v := range values {
if v.value == "" {
continue
}
if pickedValue != "" {
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
}
pickedName = v.name
pickedValue = v.value
}
if pickedValue == "" {
return "", baseFlagErrorf("specify --title")
}
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
return "", resolveValidationError(
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
)
}
return pickedValue, nil
}
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
raw, err := readURLResolveInput(runtime)
if err != nil {
return err
}
parsed, err := parseResolveURL(raw)
if err != nil {
return err
}
switch classifyBaseURL(parsed) {
case "base_url":
out := resolveBaseURL(parsed)
enrichBaseResolveHint(runtime, out)
runtime.OutFormat(out, nil, nil)
return nil
case "wiki_url":
out, err := resolveWikiBaseURL(runtime, parsed)
if err != nil {
return err
}
runtime.OutFormat(out, nil, nil)
return nil
case "record_share_url":
out, err := resolveRecordShareURL(runtime, parsed)
if err != nil {
return err
}
runtime.OutFormat(out, nil, nil)
return nil
case "form_share_url":
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
return nil
case "view_share_url":
return resolveValidationError(
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
case "dashboard_share_url":
return resolveValidationError(
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
case "workspace_url":
return resolveValidationError(
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
case "add_record_url":
return resolveValidationError(
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
default:
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
}
}
func parseResolveURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
}
return parsed, nil
}
func classifyBaseURL(u *url.URL) string {
path := normalizeResolvePath(u.Path)
switch {
case pathSegmentExists(path, "/base/workspace/"):
return "workspace_url"
case pathSegmentExists(path, "/base/add/"):
return "add_record_url"
case pathSegmentExists(path, "/base/"):
return "base_url"
case pathSegmentExists(path, "/wiki/"):
return "wiki_url"
case pathSegmentExists(path, "/record/"):
return "record_share_url"
case pathSegmentExists(path, "/share/base/form/"):
return "form_share_url"
case pathSegmentExists(path, "/share/base/view/"):
return "view_share_url"
case pathSegmentExists(path, "/share/base/dashboard/"):
return "dashboard_share_url"
default:
return ""
}
}
func resolveBaseURL(u *url.URL) map[string]interface{} {
query := u.Query()
out := map[string]interface{}{
"input_type": "base_url",
"resource_type": "bitable",
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
}
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
out["table_id"] = tableID
}
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
out["view_id"] = viewID
}
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
out["record_id"] = recordID
}
return out
}
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
if err != nil {
return nil, err
}
node := common.GetMap(data, "node")
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
if objType != "bitable" {
return nil, resolveValidationError(
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
"Use the corresponding skill for that resource, or provide a Base URL.",
)
}
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
if baseToken == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
}
return map[string]interface{}{
"input_type": "wiki_url",
"resource_type": "bitable",
"wiki_node_token": token,
"base_token": baseToken,
"title": common.GetString(node, "title"),
"hint": resolveHint("", nil),
}, nil
}
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
shareToken := firstPathSegmentAfter(u.Path, "/record/")
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"input_type": "record_share_url",
"resource_type": "bitable",
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
"base_token": common.GetString(data, "base_token"),
"table_id": common.GetString(data, "table_id"),
"record_id": common.GetString(data, "record_id"),
}
enrichRecordShareResolveHint(runtime, out)
return out, nil
}
func resolveFormShareURL(u *url.URL) map[string]interface{} {
return map[string]interface{}{
"input_type": "form_share_url",
"resource_type": "bitable_form",
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
"hint": map[string]interface{}{
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
},
}
}
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
query, err := readTitleResolveQuery(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
if err != nil {
return err
}
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
switch len(candidates) {
case 0:
return resolveValidationError(
"No Base matched this title or keyword.",
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
)
case 1:
out := map[string]interface{}{
"input_type": "title_query",
"resource_type": "bitable",
"title": candidates[0]["title"],
"base_token": candidates[0]["base_token"],
"url": candidates[0]["url"],
"owner_name": candidates[0]["owner_name"],
"update_time": candidates[0]["update_time"],
"hint": resolveHint("", nil),
}
runtime.OutFormat(out, nil, nil)
return nil
default:
runtime.OutFormat(map[string]interface{}{
"input_type": "title_query",
"resource_type": "bitable",
"candidates": candidates,
"hint": map[string]interface{}{
"next_step": baseTitleResolveHint,
},
}, nil, nil)
return nil
}
}
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
if baseToken == "" || tableID == "" {
out["hint"] = resolveHint("", nil)
return
}
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
if err != nil {
out["hint"] = resolveHint(tableID, nil)
return
}
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
}
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
hint := map[string]interface{}{}
if baseToken != "" && tableID != "" && recordID != "" {
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
hint["record_data"] = formatResolvedRecordData(record)
}
}
if baseToken != "" && tableID != "" {
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
}
}
out["hint"] = resolveHint(tableID, hint)
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
}
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
body := map[string]interface{}{"record_id_list": []string{recordID}}
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
return handleBaseAPIResult(result, err, "batch get records")
}
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
fieldIDs := common.GetSlice(record, "field_id_list")
fieldNames := common.GetSlice(record, "fields")
rows := common.GetSlice(record, "data")
data := map[string]interface{}{}
if len(rows) > 0 {
if values, ok := rows[0].([]interface{}); ok {
for i, value := range values {
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
}
}
}
return data
}
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
if index < len(fieldIDs) {
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
return fieldID
}
}
if index < len(fieldNames) {
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
return fieldName
}
}
return fmt.Sprintf("field_%d", index+1)
}
func recordShareNextStep(baseToken, tableID, recordID string) string {
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
}
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
hint := map[string]interface{}{}
for key, value := range extra {
hint[key] = value
}
if strings.TrimSpace(tableID) != "" {
hint["next_step"] = nextStepRecordList
} else {
hint["next_step"] = nextStepBaseBlockList
}
return hint
}
func buildTitleResolveSearchBody(query string) map[string]interface{} {
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
return map[string]interface{}{
"query": query,
"page_size": 5,
"doc_filter": filter,
"wiki_filter": filter,
}
}
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
candidates := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
row, _ := item.(map[string]interface{})
meta, _ := row["result_meta"].(map[string]interface{})
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
continue
}
token := strings.TrimSpace(common.GetString(meta, "token"))
if token == "" {
continue
}
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
if title == "" {
title = strings.TrimSpace(common.GetString(row, "title"))
}
candidates = append(candidates, map[string]interface{}{
"title": title,
"base_token": token,
"url": common.GetString(meta, "url"),
"owner_name": common.GetString(meta, "owner_name"),
"update_time": common.GetString(meta, "update_time_iso"),
})
}
return candidates
}
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
func stripSearchHighlight(s string) string {
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
}
func resolveValidationError(message, hint string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
}
func normalizeResolvePath(path string) string {
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func pathSegmentExists(path, prefix string) bool {
return firstPathSegmentAfter(path, prefix) != ""
}
func firstPathSegmentAfter(path, prefix string) string {
path = normalizeResolvePath(path)
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := path[len(prefix):]
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
rest = rest[:idx]
}
return strings.TrimSpace(rest)
}
func valueOrUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "an unknown resource type"
}
return s
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -1,454 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBaseURLResolveBaseURL(t *testing.T) {
t.Run("with coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("missing Base coordinates: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
fields, _ := hint["fields"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("base only", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepBaseBlockList {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bas123",
"title": "Demo Base",
},
},
},
})
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
t.Fatalf("unexpected output: %#v", data)
}
})
t.Run("non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
},
},
})
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not Base") {
t.Fatalf("err=%v, want non-Base validation error", err)
}
})
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
recordData, _ := hint["record_data"].(map[string]interface{})
fields, _ := hint["fields"].(map[string]interface{})
nextStep, _ := hint["next_step"].(string)
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("enrichment failure still returns meta", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep, _ := hint["next_step"].(string)
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["record_data"]; ok {
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
}
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_share_token": shareToken,
"base_token": baseToken,
"table_id": tableID,
"record_id": recordID,
},
},
}
}
func TestBaseURLResolveFormShareURL(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
t.Fatalf("unexpected output: %#v", data)
}
}
func TestBaseURLResolveValidationErrors(t *testing.T) {
tests := []struct {
name string
rawURL string
wantText string
wantHint string
}{
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
{"not url", "bas123", "only accepts full URLs", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", tc.rawURL, "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Hint == "" {
t.Fatalf("err=%v, want typed error with hint", err)
}
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
}
if strings.Contains(p.Hint, "original /base/{base_token}") {
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
}
})
}
}
func TestBaseResolveInputXOR(t *testing.T) {
t.Run("url resolve", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v, want xor validation", err)
}
})
t.Run("title resolve", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v, want xor validation", err)
}
})
}
func TestBaseResolveHelpFlags(t *testing.T) {
for _, tc := range []struct {
shortcut string
definition common.Shortcut
primaryFlag string
primaryDesc string
aliasFlags []string
}{
{
shortcut: "+url-resolve",
definition: BaseURLResolve,
primaryFlag: "url",
primaryDesc: "Base/Wiki/record-share URL to resolve",
aliasFlags: []string{"query"},
},
{
shortcut: "+title-resolve",
definition: BaseTitleResolve,
primaryFlag: "title",
primaryDesc: "Base title keyword",
aliasFlags: []string{"query", "url"},
},
} {
t.Run(tc.shortcut, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tc.definition.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup(tc.primaryFlag)
primaryUsage := ""
if primary != nil {
primaryUsage = primary.Usage
}
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
}
for _, aliasFlag := range tc.aliasFlags {
alias := cmd.Flags().Lookup(aliasFlag)
if alias == nil || !alias.Hidden {
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
}
}
})
}
}
func TestBaseTitleResolve(t *testing.T) {
t.Run("single result", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub([]interface{}{
map[string]interface{}{
"title_highlighted": "Sales <h>Pipeline</h>",
"result_meta": map[string]interface{}{
"doc_types": "BITABLE",
"token": "bas123",
"url": "https://example.larkoffice.com/base/bas123",
"owner_name": "Alice",
"update_time_iso": "2026-06-09T10:00:00+08:00",
},
},
}))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "Pipeline", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
t.Fatalf("unexpected output: %#v", data)
}
})
t.Run("multiple results and filter non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub([]interface{}{
map[string]interface{}{
"title_highlighted": "Doc hit",
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
},
map[string]interface{}{
"title_highlighted": "Base <h>One</h>",
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
},
map[string]interface{}{
"title_highlighted": "Base <h>Two</h>",
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
},
}))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--url", "Base", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != 2 {
t.Fatalf("candidates=%#v, want 2", data["candidates"])
}
})
t.Run("no results", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub(nil))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "missing", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
t.Fatalf("err=%v, want no result validation", err)
}
})
t.Run("query too long", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
t.Fatalf("err=%v, want query length validation", err)
}
})
}
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
if items == nil {
items = []interface{}{}
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"res_units": items,
},
},
}
}
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"total": 2,
"fields": []interface{}{
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
},
},
},
}
}
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{recordID},
"field_id_list": []interface{}{"fld_name", "fld_status"},
"fields": []interface{}{"Name", "Status"},
"data": []interface{}{[]interface{}{"Alice", "Done"}},
},
},
}
}

View File

@@ -155,7 +155,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+url-resolve", "+title-resolve",
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",

View File

@@ -8,8 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all base shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
BaseURLResolve,
BaseTitleResolve,
BaseBaseBlockList,
BaseBaseBlockCreate,
BaseBaseBlockMove,

View File

@@ -49,9 +49,21 @@ type RuntimeContext struct {
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
stdinConsumed bool // set when any flag has consumed stdin (`-`); used so out-of-band binary readers (e.g. sheets +table-put --dataframe) can refuse a second stdin consumer instead of racing for an already-empty stream
}
// StdinConsumed reports whether stdin has already been consumed by an Input
// flag's `-` form via resolveInputFlags. Out-of-band binary readers that read
// stdin themselves (currently sheets +table-put / +workbook-create --dataframe)
// must check this before reading — a process has a single stdin, so two
// consumers would race and one would see an empty stream.
func (ctx *RuntimeContext) StdinConsumed() bool { return ctx.stdinConsumed }
// MarkStdinConsumed marks stdin as consumed. Out-of-band binary readers must
// call this after they read stdin so a later Input-flag `-` is rejected cleanly
// instead of racing on an empty stream.
func (ctx *RuntimeContext) MarkStdinConsumed() { ctx.stdinConsumed = true }
// ── Identity ──
// As returns the current identity.
@@ -1049,8 +1061,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
}
// A process has a single stdin, so we reject a second Input flag
// trying to use `-` after the first one has already consumed it.
// stdinConsumed also covers out-of-band readers like sheets +table-put
// --dataframe (binary, doesn't go through Input). A process has a
// single stdin, so we reject a second consumer regardless of source.
if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--"+fl.Name).

View File

@@ -55,7 +55,7 @@ var docCoverAllowedContentTypes = map[string]string{
var DocResourceDownload = common.Shortcut{
Service: "docs",
Command: "+resource-download",
Command: "resource-download",
Description: "Download a document resource (type=cover downloads the cover image content)",
Risk: "read",
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
@@ -154,7 +154,7 @@ var DocResourceDownload = common.Shortcut{
var DocResourceUpdate = common.Shortcut{
Service: "docs",
Command: "+resource-update",
Command: "resource-update",
Description: "Upload and update a document resource (type=cover)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
@@ -256,7 +256,7 @@ var DocResourceUpdate = common.Shortcut{
var DocResourceDelete = common.Shortcut{
Service: "docs",
Command: "+resource-delete",
Command: "resource-delete",
Description: "Delete a document resource (type=cover is idempotent when empty)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},

View File

@@ -48,7 +48,7 @@ func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"+resource-download",
"resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover",
@@ -95,7 +95,7 @@ func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T)
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"+resource-download",
"resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover.png",
@@ -116,7 +116,7 @@ func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
err := mountAndRunDocs(t, DocResourceDelete, []string{
"+resource-delete",
"resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
@@ -146,7 +146,7 @@ func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceDelete, []string{
"+resource-delete",
"resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
@@ -195,7 +195,7 @@ func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *tes
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"+resource-update",
"resource-update",
"--doc", documentID,
"--type", "cover",
"--file", "cover.png",
@@ -241,7 +241,7 @@ func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"+resource-update",
"resource-update",
"--doc", "doxcnCoverValidate1",
"--type", "cover",
"--file", "cover.png",
@@ -258,7 +258,7 @@ func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"+resource-update",
"resource-update",
"--doc", "doxcnCoverValidateRequired1",
"--type", "cover",
"--as", "bot",
@@ -273,7 +273,7 @@ func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"+resource-update",
"resource-update",
"--doc", "doxcnCoverURLValidate1",
"--type", "cover",
"--url", "https://127.0.0.1/cover.png",
@@ -617,7 +617,7 @@ func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
for _, shortcut := range Shortcuts() {
got[shortcut.Command] = true
}
for _, want := range []string{"+resource-download", "+resource-update", "+resource-delete"} {
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
if !got[want] {
t.Fatalf("Shortcuts() missing %s", want)
}

View File

@@ -1,861 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"html"
"net/url"
"regexp"
"strings"
"unicode/utf8"
)
type imMarkdownContext struct {
baseURL string
blockquoteDepth int
}
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
type imMarkdownTagHandler struct {
closeRE *regexp.Regexp
handle imMarkdownHandleFunc
}
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
imMarkdownHandlers[tag] = imMarkdownTagHandler{
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
handle: handle,
}
}
var (
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
)
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
func init() {
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
for level := 1; level <= 9; level++ {
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
}
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
registerIMMarkdownHandler("table", handleIMMarkdownTable)
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
registerIMMarkdownHandler("code", handleIMMarkdownCode)
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
registerIMMarkdownHandler("img", handleIMMarkdownImage)
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
registerIMMarkdownHandler("source", handleIMMarkdownSource)
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
}
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
}
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
doc, ok := data["document"].(map[string]interface{})
if !ok {
return
}
content, ok := doc["content"].(string)
if !ok {
return
}
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
}
func newIMMarkdownContext(docInput string) imMarkdownContext {
base := "https://larkoffice.com"
raw := strings.TrimSpace(docInput)
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
base = extracted
}
return imMarkdownContext{baseURL: base}
}
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
c.blockquoteDepth++
return c
}
func (c imMarkdownContext) inBlockquote() bool {
return c.blockquoteDepth > 0
}
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
// intentionally strips host information, so it cannot serve this formatting path.
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
if raw == "" {
return "", false
}
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host, true
}
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
idx := strings.Index(raw, marker)
if idx <= 0 {
continue
}
candidate := strings.Trim(raw[:idx], "/")
if candidate == "" {
continue
}
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host, true
}
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
return "https://" + u.Host, true
}
}
return "", false
}
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
var out strings.Builder
for offset := 0; offset < len(content); {
// Scan only to the next XML-like opening tag. Plain Markdown text between
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
if loc == nil {
out.WriteString(content[offset:])
break
}
start := offset + loc[0]
openEnd := offset + loc[1]
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
handler, ok := imMarkdownHandlers[tag]
if !ok {
// Unknown tags are left intact. im-markdown only downgrades tags with
// explicit handlers so future server output does not get guessed at.
out.WriteString(content[offset:openEnd])
offset = openEnd
continue
}
out.WriteString(content[offset:start])
opening := content[start:openEnd]
attrs := parseIMMarkdownAttrs(opening)
if isSelfClosingIMMarkdownTag(opening) {
out.WriteString(handler.handle(opening, "", attrs, imCtx))
offset = openEnd
continue
}
// Use the handler's precompiled close regexp to find the matching end tag.
// Depth tracking keeps nested same-name containers paired correctly.
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
if !found {
// Malformed or truncated fragments are preserved as-is from the opening
// tag onward; do not drop content when the XML-ish structure is incomplete.
out.WriteString(content[start:])
break
}
segment := content[start:closeEnd]
inner := content[openEnd:closeStart]
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
offset = closeEnd
}
return out.String()
}
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
depth := 1
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
start := from + loc[0]
end := from + loc[1]
token := content[start:end]
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
depth--
if depth == 0 {
return start, end, true
}
continue
}
if !isSelfClosingIMMarkdownTag(token) {
depth++
}
}
return 0, 0, false
}
func parseIMMarkdownAttrs(opening string) map[string]string {
attrs := map[string]string{}
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
value := match[2]
if value == "" {
value = match[3]
}
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
}
return attrs
}
func isSelfClosingIMMarkdownTag(tag string) bool {
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
}
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if text == "" {
return ""
}
return "# " + text
}
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if text == "" {
return ""
}
markdownLevel := level
if markdownLevel > 6 {
markdownLevel = 6
}
return strings.Repeat("#", markdownLevel) + " " + text
}
}
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
if imCtx.inBlockquote() {
return body + "\n"
}
return body
}
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return convertIMMarkdownListItems(inner, false, imCtx)
}
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return convertIMMarkdownListItems(inner, true, imCtx)
}
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
prefix := "-"
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
prefix = strings.TrimSuffix(seq, ".") + "."
}
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
}
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
emoji := strings.TrimSpace(attrs["emoji"])
if emoji != "" {
if body == "" {
body = emoji
} else {
body = emoji + " " + body
}
}
if body == "" {
return "---\n---"
}
return fmt.Sprintf("---\n%s\n---", body)
}
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
if body == "" {
return ""
}
lines := strings.Split(body, "\n")
for i, line := range lines {
if strings.TrimSpace(line) == "" {
lines[i] = ">"
continue
}
lines[i] = "> " + line
}
return strings.Join(lines, "\n")
}
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
}
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return body + "\n"
}
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
return ""
}
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
return imMarkdownInlineCode(segment)
}
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
lang := strings.TrimSpace(attrs["lang"])
code := strings.TrimSpace(inner)
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
code = match[1]
}
return imMarkdownFencedCode(html.UnescapeString(code), lang)
}
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
return imMarkdownInlineCode(markdownPlainText(inner))
}
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
expr := strings.TrimSpace(markdownPlainText(inner))
if expr == "" {
return ""
}
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
}
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
return "---"
}
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
if href == "" {
return ""
}
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
return fmt.Sprintf("![%s](%s)", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
}
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
name := strings.TrimSpace(attrs["name"])
if name == "" {
return ""
}
return imMarkdownInlineCode(name)
}
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
return imMarkdownInlineCode(label)
}
}
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
for _, attrName := range attrNames {
if strings.TrimSpace(attrs[attrName]) != "" {
return imMarkdownInlineCode(label)
}
}
return ""
}
}
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
token := strings.TrimSpace(attrs["token"])
if token == "" {
return imMarkdownInlineCode(segment)
}
label := "sheet"
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
label = "sheet " + sheetID
}
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
}
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
href := strings.TrimSpace(attrs["href"])
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
if href == "" {
return name
}
return markdownLink(name, href)
}
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return "**" + body + "**"
}
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return "*" + body + "*"
}
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return "~~" + body + "~~"
}
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
}
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
href := strings.TrimSpace(attrs["href"])
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
if href == "" {
return text
}
return markdownLink(text, href)
}
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
case "user":
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
if userID == "" {
return name
}
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
case "doc":
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
return markdownLink(title, href)
}
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
if docID == "" {
return imMarkdownInlineCode(segment)
}
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
case "citation":
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
return markdownLink(text, href)
}
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
}
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
default:
return imMarkdownInlineCode(segment)
}
}
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
// Rows and cells are matched with tag-depth tracking instead of non-greedy
// regex captures. A table nested inside a cell can contain its own </tr> and
// </td>; treating those as the outer row/cell boundary corrupts the table.
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
if len(rowBodies) == 0 {
return imMarkdownInlineCode(segment)
}
rows := make([][]string, 0, len(rowBodies))
for _, rowBody := range rowBodies {
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
if len(cellBodies) == 0 {
continue
}
row := make([]string, 0, len(cellBodies))
for _, cellBody := range cellBodies {
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
}
rows = append(rows, row)
}
if len(rows) == 0 {
return imMarkdownInlineCode(segment)
}
cols := 0
for _, row := range rows {
if len(row) > cols {
cols = len(row)
}
}
var out strings.Builder
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
separator := make([]string, cols)
for i := range separator {
separator[i] = "-"
}
writeIMMarkdownTableRow(&out, separator)
for _, row := range rows[1:] {
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
}
return strings.TrimRight(out.String(), "\n")
}
// extractIMMarkdownElementBodies returns the inner content of each top-level
// element matched by tagRE. tagRE must expose the optional closing slash as its
// first capture group, matching the row/cell regexes above.
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
var bodies []string
for offset := 0; offset < len(content); {
loc := tagRE.FindStringSubmatchIndex(content[offset:])
if loc == nil {
break
}
openStart := offset + loc[0]
openEnd := offset + loc[1]
opening := content[openStart:openEnd]
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
offset = openEnd
continue
}
if isSelfClosingIMMarkdownTag(opening) {
bodies = append(bodies, "")
offset = openEnd
continue
}
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
if !found {
break
}
bodies = append(bodies, content[openEnd:closeStart])
offset = closeEnd
}
return bodies
}
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
depth := 1
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
start := from + loc[0]
end := from + loc[1]
token := content[start:end]
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
depth--
if depth == 0 {
return start, end, true
}
continue
}
if !isSelfClosingIMMarkdownTag(token) {
depth++
}
}
return 0, 0, false
}
func normalizeIMMarkdownTableCell(cell string) string {
const brPlaceholder = "\x00BR\x00"
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
if name == "at" {
return tag
}
return ""
})
cell = html.UnescapeString(cell)
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
cell = strings.ReplaceAll(cell, " \n", "<br>")
cell = strings.ReplaceAll(cell, "\n", "<br>")
cell = strings.ReplaceAll(cell, "|", `\|`)
lines := strings.Fields(cell)
if len(lines) == 0 {
return ""
}
return strings.Join(lines, " ")
}
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
out.WriteString("| ")
out.WriteString(strings.Join(row, " | "))
out.WriteString(" |\n")
}
func padIMMarkdownTableRow(row []string, cols int) []string {
if len(row) >= cols {
return row
}
padded := make([]string, cols)
copy(padded, row)
return padded
}
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
var out strings.Builder
for offset, index := 0, 1; offset < len(inner); {
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
if loc == nil {
break
}
openStart := offset + loc[0]
openEnd := offset + loc[1]
opening := inner[openStart:openEnd]
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
if !found {
break
}
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
if body != "" {
prefix := "-"
if ordered {
attrs := parseIMMarkdownAttrs(opening)
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
prefix = strings.TrimSuffix(seq, ".") + "."
} else {
prefix = fmt.Sprintf("%d.", index)
}
index++
}
out.WriteString(prefix)
out.WriteString(" ")
out.WriteString(indentIMMarkdownListContinuation(body))
out.WriteString("\n")
}
offset = closeEnd
}
return strings.TrimRight(out.String(), "\n")
}
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
depth := 1
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
start := from + loc[0]
end := from + loc[1]
token := content[start:end]
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
depth--
if depth == 0 {
return start, end, true
}
continue
}
if !isSelfClosingIMMarkdownTag(token) {
depth++
}
}
return 0, 0, false
}
func indentIMMarkdownListContinuation(body string) string {
return strings.ReplaceAll(body, "\n", "\n ")
}
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
match := imMarkdownLinkRE.FindStringSubmatch(inner)
if match == nil {
return "", "", false
}
href := match[1]
if href == "" {
href = match[2]
}
text := strings.TrimSpace(markdownPlainText(match[3]))
if text == "" {
text = href
}
return text, html.UnescapeString(href), true
}
func markdownPlainText(s string) string {
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func markdownLinkLabelText(s string) string {
text := markdownPlainText(s)
if !strings.Contains(text, "---") {
return text
}
lines := strings.Split(text, "\n")
kept := lines[:0]
for _, line := range lines {
if strings.TrimSpace(line) == "---" {
continue
}
kept = append(kept, line)
}
return strings.TrimSpace(strings.Join(kept, "\n"))
}
func markdownLink(text, href string) string {
cleanHref := strings.TrimSpace(href)
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
}
func escapeMarkdownLinkText(text string) string {
text = strings.ReplaceAll(text, `\`, `\\`)
text = strings.ReplaceAll(text, `[`, `\[`)
text = strings.ReplaceAll(text, `]`, `\]`)
return text
}
func escapeMarkdownLinkDestination(href string) string {
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
// characters that can terminate or split the Markdown link destination.
var out strings.Builder
out.Grow(len(href))
for i := 0; i < len(href); {
if href[i] == '%' {
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
out.WriteString(href[i : i+3])
i += 3
} else {
writePercentEncodedByte(&out, href[i])
i++
}
continue
}
if href[i] < utf8.RuneSelf {
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
writePercentEncodedByte(&out, href[i])
} else {
out.WriteByte(href[i])
}
i++
continue
}
r, size := utf8.DecodeRuneInString(href[i:])
if r == utf8.RuneError && size == 1 {
writePercentEncodedByte(&out, href[i])
i++
continue
}
for _, b := range []byte(href[i : i+size]) {
writePercentEncodedByte(&out, b)
}
i += size
}
return out.String()
}
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
if b <= ' ' || b >= 0x7f {
return true
}
switch b {
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
return true
default:
return false
}
}
func writePercentEncodedByte(out *strings.Builder, b byte) {
const hex = "0123456789ABCDEF"
out.WriteByte('%')
out.WriteByte(hex[b>>4])
out.WriteByte(hex[b&0x0f])
}
func isHexDigit(b byte) bool {
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
}
func imMarkdownInlineCode(s string) string {
maxRun := 0
run := 0
for _, r := range s {
if r == '`' {
run++
if run > maxRun {
maxRun = run
}
continue
}
run = 0
}
fence := strings.Repeat("`", maxRun+1)
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
return fence + " " + s + " " + fence
}
return fence + s + fence
}
func imMarkdownFencedCode(code, lang string) string {
maxRun := 0
for _, line := range strings.Split(code, "\n") {
if run := leadingBacktickRun(line); run > maxRun {
maxRun = run
}
}
fenceLen := maxRun + 1
if fenceLen < 3 {
fenceLen = 3
}
fence := strings.Repeat("`", fenceLen)
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
}
func leadingBacktickRun(s string) int {
run := 0
for _, r := range s {
if r != '`' {
break
}
run++
}
return run
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ import (
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
@@ -72,9 +72,6 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
if isIMMarkdownFetch(runtime) {
applyFetchIMMarkdown(data, runtime.Str("doc"))
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
@@ -88,7 +85,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": effectiveFetchFormat(runtime),
"format": runtime.Str("doc-format"),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
@@ -125,14 +122,6 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
func effectiveFetchFormat(runtime *common.RuntimeContext) string {
format := strings.TrimSpace(runtime.Str("doc-format"))
if format == "im-markdown" {
return "markdown"
}
return format
}
func resolveFetchLang(runtime *common.RuntimeContext) string {
if runtime.Changed("lang") {
return strings.TrimSpace(runtime.Str("lang"))

View File

@@ -6,12 +6,9 @@ package doc
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -107,369 +104,6 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
}
}
func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "revision-id", "42")
mustSetFetchFlag(t, runtime, "detail", "full")
body := buildFetchBody(runtime)
if got := body["revision_id"]; got != 42 {
t.Fatalf("revision_id = %#v, want 42", got)
}
exportOption, _ := body["export_option"].(map[string]interface{})
want := map[string]interface{}{
"export_block_id": true,
"export_style_attrs": true,
"export_cite_extra_data": true,
}
if !reflect.DeepEqual(exportOption, want) {
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
}
}
func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "detail", "with-ids")
body := buildFetchBody(runtime)
exportOption, _ := body["export_option"].(map[string]interface{})
want := map[string]interface{}{
"export_block_id": true,
}
if !reflect.DeepEqual(exportOption, want) {
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
}
}
func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "scope", "section")
mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
body := buildFetchBody(runtime)
want := map[string]interface{}{
"read_mode": "section",
"start_block_id": "blk_heading",
}
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
t.Fatalf("read_option = %#v, want %#v", got, want)
}
}
func TestBuildReadOptionModes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
want map[string]interface{}
}{
{
name: "full omits read option",
setFlags: map[string]string{
"scope": "full",
},
want: nil,
},
{
name: "outline with max depth",
setFlags: map[string]string{
"scope": "outline",
"max-depth": "3",
},
want: map[string]interface{}{
"read_mode": "outline",
"max_depth": "3",
},
},
{
name: "range with block ids and context",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"end-block-id": "blk_end",
"context-before": "2",
"context-after": "1",
"max-depth": "0",
},
want: map[string]interface{}{
"read_mode": "range",
"start_block_id": "blk_start",
"end_block_id": "blk_end",
"context_before": "2",
"context_after": "1",
"max_depth": "0",
},
},
{
name: "keyword with query",
setFlags: map[string]string{
"scope": "keyword",
"keyword": "foo|bar",
"context-before": "1",
},
want: map[string]interface{}{
"read_mode": "keyword",
"keyword": "foo|bar",
"context_before": "1",
},
},
{
name: "section keeps unlimited depth omitted",
setFlags: map[string]string{
"scope": "section",
"start-block-id": "blk_heading",
"max-depth": "-1",
},
want: map[string]interface{}{
"read_mode": "section",
"start_block_id": "blk_heading",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
wantParam string
wantParams []string
}{
{
name: "negative context before",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"context-before": "-1",
},
wantParam: "--context-before",
},
{
name: "negative context after",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"context-after": "-1",
},
wantParam: "--context-after",
},
{
name: "max depth below unlimited sentinel",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"max-depth": "-2",
},
wantParam: "--max-depth",
},
{
name: "range needs boundary",
setFlags: map[string]string{
"scope": "range",
},
wantParams: []string{
"--start-block-id",
"--end-block-id",
},
},
{
name: "keyword needs keyword",
setFlags: map[string]string{
"scope": "keyword",
},
wantParam: "--keyword",
},
{
name: "section needs start block",
setFlags: map[string]string{
"scope": "section",
},
wantParam: "--start-block-id",
},
{
name: "unknown scope",
setFlags: map[string]string{
"scope": "bad",
},
wantParam: "--scope",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
err := validateReadModeFlags(runtime)
if err == nil {
t.Fatal("validateReadModeFlags() succeeded, want error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
})
}
}
func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
}{
{
name: "outline",
setFlags: map[string]string{
"scope": "outline",
},
},
{
name: "range with end block",
setFlags: map[string]string{
"scope": "range",
"end-block-id": "blk_end",
},
},
{
name: "keyword with keyword",
setFlags: map[string]string{
"scope": "keyword",
"keyword": "bug|缺陷",
},
},
{
name: "section with start block",
setFlags: map[string]string{
"scope": "section",
"start-block-id": "blk_heading",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
if err := validateReadModeFlags(runtime); err != nil {
t.Fatalf("validateReadModeFlags() error = %v", err)
}
})
}
}
func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
wantParam string
}{
{
name: "invalid doc",
setFlags: map[string]string{
"doc": "https://example.com/sheets/sht_token",
},
wantParam: "--doc",
},
{
name: "invalid scope",
setFlags: map[string]string{
"scope": "bad",
},
wantParam: "--scope",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
err := validateFetchV2(context.Background(), runtime)
if err == nil {
t.Fatal("validateFetchV2() succeeded, want error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
})
}
}
func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
}{
{
name: "xml format",
setFlags: map[string]string{
"doc-format": "xml",
"detail": "full",
},
},
{
name: "markdown simple detail",
setFlags: map[string]string{
"doc-format": "markdown",
"detail": "simple",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
data := map[string]interface{}{}
if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
t.Fatalf("warning = %q, want empty", got)
}
if _, ok := data["warnings"]; ok {
t.Fatalf("unexpected warnings: %#v", data["warnings"])
}
})
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -507,54 +141,36 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
}
}
func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": "im-markdown",
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
if got, want := dry.API[0].Body["format"], "markdown"; got != want {
t.Fatalf("dry-run format = %#v, want %q", got, want)
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()
for _, format := range []string{"markdown", "im-markdown"} {
for _, detail := range []string{"with-ids", "full"} {
t.Run(format+"/"+detail, func(t *testing.T) {
t.Parallel()
for _, detail := range []string{"with-ids", "full"} {
t.Run(detail, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": format,
"detail": detail,
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
if exportOption == nil {
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
}
if got := exportOption["export_block_id"]; got != false {
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_style_attrs"]; got != false {
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_cite_extra_data"]; got != false {
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
}
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": "markdown",
"detail": detail,
})
}
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
if exportOption == nil {
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
}
if got := exportOption["export_block_id"]; got != false {
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_style_attrs"]; got != false {
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_cite_extra_data"]; got != false {
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
}
})
}
}
@@ -645,107 +261,6 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
}
}
func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
Body: map[string]interface{}{
"code": 999999,
"msg": "fetch failed",
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchAPIError",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("mountAndRunDocs() succeeded, want API error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
}
if p.Code != 999999 {
t.Errorf("code = %d, want 999999", p.Code)
}
if p.Message != "fetch failed" {
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
}
}
func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchIMMarkdown",
"revision_id": float64(1),
"content": strings.Join([]string{
`<title>Doc Title</title>`,
`<callout emoji="💡">Read **this**.</callout>`,
`<bookmark name="Example" href="https://example.com"></bookmark>`,
}, "\n\n"),
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchIMMarkdown",
"--doc-format", "im-markdown",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
content, _ := doc["content"].(string)
for _, want := range []string{
"# Doc Title",
"---\n💡 Read **this**.\n---",
"[Example](https://example.com)",
} {
if !strings.Contains(content, want) {
t.Fatalf("converted content missing %q:\n%s", want, content)
}
}
if strings.Contains(content, "<title>") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") {
t.Fatalf("converted content still contains downgraded XML tags:\n%s", content)
}
}
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
@@ -776,7 +291,6 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
if err == nil {
t.Fatal("expected v2-only validation error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
for _, want := range tt.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
@@ -802,14 +316,6 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()

View File

@@ -59,7 +59,7 @@ var DriveExport = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateExport(exportParamsFromFlags(runtime))
return ValidateExport(exportParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
@@ -117,11 +117,8 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
}
}
// validateExport runs the CLI-level export constraint checks. Unexported because
// only drive +export's Validate consumes it directly; sheets +workbook-export
// reuses RunExport / PlanExportDryRun but inlines its own (sheet-specific)
// validation, so there is no cross-package call site to keep exported.
func validateExport(p ExportParams) error {
// ValidateExport runs the CLI-level export constraint checks.
func ValidateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}

View File

@@ -469,29 +469,6 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesSend audio rejects non-opus local file", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"audio": "./voice.mp3",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend audio accepts opus and ogg local files", func(t *testing.T) {
for _, audio := range []string{"./voice.opus", "./voice.ogg"} {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"audio": audio,
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate(%q) unexpected error = %v", audio, err)
}
}
})
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -515,17 +492,6 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesReply audio rejects non-opus local file", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"audio": "./voice.mp3",
}, nil)
err := ImMessagesReply.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
}
})
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"thread": "bad_thread",

View File

@@ -1048,42 +1048,6 @@ func detectIMFileType(filePath string) string {
}
}
const (
audioMessageInputDesc = "audio file key (file_xxx), URL, or cwd-relative local path for a voice message (absolute paths and .. are rejected); local paths and URLs must be Opus (.opus or Ogg Opus .ogg). For mp3/wav, convert to .opus first, or use --file to send as an attachment"
audioMessageHint = "Convert non-Opus audio to .opus and use --audio for a voice message, for example: ffmpeg -i input.mp3 -acodec libopus -ac 1 -ar 16000 output.opus. To send the original mp3/wav as an attachment, use --file instead."
)
func validateAudioMessageInput(flagName, value string) error {
value = strings.TrimSpace(value)
if value == "" || isMediaKey(value) {
return nil
}
ext := audioInputExt(value)
if ext == "" {
return nil
}
if ext == ".opus" || ext == ".ogg" {
return nil
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"%s supports only Opus audio files for audio messages, such as .opus files or Ogg Opus (.ogg) files",
flagName,
).WithParam(flagName).WithHint("%s", audioMessageHint)
}
func audioInputExt(value string) string {
if isURL(value) {
parsed, err := url.Parse(value)
if err != nil {
return ""
}
return strings.ToLower(path.Ext(parsed.Path))
}
return strings.ToLower(filepath.Ext(value))
}
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files

View File

@@ -13,7 +13,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -51,56 +50,6 @@ func TestDetectIMFileType(t *testing.T) {
}
}
func TestValidateAudioMessageInput(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{name: "empty", value: ""},
{name: "existing file key", value: "file_abc"},
{name: "opus file", value: "./voice.opus"},
{name: "ogg opus file", value: "./voice.ogg"},
{name: "uppercase opus", value: "./VOICE.OPUS"},
{name: "mp3 local file", value: "./voice.mp3", wantErr: true},
{name: "wav local file", value: "./voice.wav", wantErr: true},
{name: "extensionless local path", value: "./voice"},
{name: "opus url", value: "https://example.com/voice.opus?download=1"},
{name: "ogg url", value: "https://example.com/voice.ogg?download=1"},
{name: "mp3 url", value: "https://example.com/voice.mp3?download=1", wantErr: true},
{name: "extensionless url", value: "https://example.com/download?id=1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAudioMessageInput("--audio", tt.value)
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("validateAudioMessageInput(%q) error = %v", tt.value, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("validateAudioMessageInput(%q) error is not typed: %v", tt.value, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("ProblemOf(%q) = category %q subtype %q", tt.value, p.Category, p.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--audio" {
t.Fatalf("validateAudioMessageInput(%q) param = %q, want --audio", tt.value, validationErr.Param)
}
if !strings.Contains(p.Hint, "use --file") || !strings.Contains(p.Hint, "ffmpeg") {
t.Fatalf("validateAudioMessageInput(%q) hint = %q, want --file and ffmpeg guidance", tt.value, p.Hint)
}
return
}
if err != nil {
t.Fatalf("validateAudioMessageInput(%q) unexpected error = %v", tt.value, err)
}
})
}
}
// TestSplitCSV covers the shared helper that replaced the three identical functions
func TestSplitCSV(t *testing.T) {
tests := []struct {

View File

@@ -33,7 +33,7 @@ var ImMessagesReply = common.Shortcut{
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: audioMessageInputDesc},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
},
@@ -100,9 +100,6 @@ var ImMessagesReply = common.Shortcut{
return err
}
}
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
return err
}
if messageId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")

View File

@@ -37,7 +37,7 @@ var ImMessagesSend = common.Shortcut{
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: audioMessageInputDesc},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatFlag := runtime.Str("chat-id")
@@ -112,9 +112,6 @@ var ImMessagesSend = common.Shortcut{
return err
}
}
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
return err
}
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
return err

View File

@@ -526,7 +526,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
"input": [
"file",
"stdin"
@@ -543,6 +543,13 @@
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
},
{
"name": "dry-run",
"kind": "system",
@@ -1380,6 +1387,13 @@
"required": "optional",
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dataframe-out",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
},
{
"name": "dry-run",
"kind": "system",
@@ -2062,19 +2076,26 @@
"name": "sheets",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON: top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
"required": "xor",
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
"input": [
"file",
"stdin"
]
},
{
"name": "dataframe",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
"input": [
"file",
"stdin"

View File

@@ -6263,7 +6263,7 @@
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [
@@ -6632,7 +6632,7 @@
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
"items": {
"type": "object",
"required": [

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
@@ -547,14 +546,10 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
}
}
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-state
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
// contract: when the spreadsheet is created but the follow-up fill can't resolve
// its first sheet, the result lands on stdout as an ok:false envelope carrying
// spreadsheet_token + reason + a structured cause field, and the process exits
// with the bare partial-failure signal — matching +table-put's tablePutPartial
// shape so agents see one consistent "side effect landed but follow-up didn't"
// contract across the sheets domain (instead of the old failed_precondition
// stderr envelope).
// its first sheet, the error must be structured and retain spreadsheet_token so
// the caller can recover instead of orphaning the new workbook.
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
@@ -568,41 +563,33 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
},
}
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
// spreadsheet already exists — exercising the partial-state path.
// spreadsheet already exists — exercising the partial-success path.
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
if err == nil {
t.Fatalf("expected partial-failure exit signal; got nil. out=%s", out)
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError exit signal; got %T %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
}
var env map[string]interface{}
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("decode envelope: %v\nraw=%s", jerr, out)
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype)
}
if ok, _ := env["ok"].(bool); ok {
t.Errorf("partial-state envelope must be ok:false; got out=%s", out)
if !strings.Contains(p.Message, "shtNEW") {
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
}
data, _ := env["data"].(map[string]interface{})
if got := data["spreadsheet_token"]; got != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW (recovery requires the token to be in the envelope)", got)
if !strings.Contains(p.Hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
}
reason, _ := data["reason"].(string)
if !strings.Contains(reason, "shtNEW") {
t.Errorf("reason = %q, want the spreadsheet token named for recovery", reason)
// The underlying fill failure is preserved as the cause so its subtype and
// log_id stay diagnosable rather than being flattened into the message.
inner := errors.Unwrap(err)
if inner == nil {
t.Fatalf("expected the underlying fill failure preserved as the cause")
}
hint, _ := data["hint"].(string)
if !strings.Contains(hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", hint)
}
// The underlying fill failure's typed shape is flattened into the cause
// field so the inner subtype stays diagnosable from the JSON envelope alone.
cause, _ := data["cause"].(map[string]interface{})
if got := cause["subtype"]; got != string(errs.SubtypeInvalidResponse) {
t.Errorf("cause.subtype = %v, want the underlying invalid_response subtype", got)
if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner)
}
}

View File

@@ -923,6 +923,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -931,8 +932,9 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON: top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -942,8 +944,9 @@ var flagDefs = map[string]commandDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},

View File

@@ -104,7 +104,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
// suggested command is guaranteed to print it.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name).WithCause(vErr)
name, vErr.Error(), command, name)
}
return nil
}

View File

@@ -89,9 +89,8 @@ var BatchUpdate = common.Shortcut{
}
// batchUpdateInput translates the user-supplied CLI-shape operations array
// into the MCP batch_update payload. Returns ValidationErrorf-typed errors
// (errs.ValidationError) on any per-op shape problem (translator validates
// each entry).
// into the MCP batch_update payload. Returns FlagErrorf-typed errors on
// any per-op shape problem (translator validates each entry).
func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {

View File

@@ -0,0 +1,621 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
//
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
// writes), single schema, optionally multi-batch. Type / format are read off
// the Arrow schema (no separate dtypes/formats maps), and per-column number
// format can be set via the field's `number_format` metadata key:
//
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
//
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
// (adopted in place by +workbook-create; created when absent by +table-put),
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
// surface is deliberately the one flag — anything that needs a different
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
// JSON payload already carries every knob.
//
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
// stream byte-exact. "-" reads from stdin directly.
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
// Matches valuesSheetName so +workbook-create adopts the brand-new
// workbook's default sheet in place (no stray empty Sheet1 left behind);
// +table-put creates Sheet1 if it doesn't already exist.
const dataframeDefaultSheetName = valuesSheetName
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
// composes a single-sheet tablePayload at the fixed default placement.
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
// so downstream is unaware of where the rows came from.
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
raw := strings.TrimSpace(rctx.Str("dataframe"))
if raw == "" {
return nil, common.ValidationErrorf("--dataframe is required")
}
data, err := readDataframeBytes(rctx, raw)
if err != nil {
return nil, err
}
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
if err != nil {
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
if err := payload.validate(); err != nil {
return nil, err
}
return payload, nil
}
// dataframeStdinCache holds the bytes read from stdin on the first call so a
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
// empty stream — stdin is single-shot, but parseDataframePayload runs
// multiple times per command invocation. Process-wide is fine: lark-cli is
// one-shot (one command per process). Tests reset by setting it back to nil.
var dataframeStdinCache []byte
// Memory caps for --dataframe. The Arrow IPC reader allocates large buffers up
// front, and arrowRecordToRows materializes every cell into [][]interface{}, so
// an unbounded input could OOM the CLI before the backend's per-write limits
// kicked in. The caps mirror the backend's per-sheet hard ceilings (200 cols,
// 50000 rows) plus a generous overall byte cap that still fits the worst-case
// dense numeric payload (200 × 50000 cells × ~25 bytes Arrow overhead ≈ 250 MB).
const (
dataframeMaxBytes = 256 * 1024 * 1024 // 256 MiB raw IPC payload
dataframeMaxCols = 200 // backend hard ceiling
dataframeMaxRows = 50000 // backend hard ceiling
)
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
// both work). `-` reads stdin verbatim — cached on first call so Validate /
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
// readCapped pulls up to dataframeMaxBytes+1 bytes from r so we can detect
// "exceeded cap" without allocating the entire oversized payload up front.
readCapped := func(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, dataframeMaxBytes+1))
if err != nil {
return nil, err
}
if len(data) > dataframeMaxBytes {
return nil, common.ValidationErrorf(
"--dataframe: payload exceeds %d MiB cap (limits CLI memory; the backend per-sheet ceilings are 200 cols × 50000 rows)",
dataframeMaxBytes/(1024*1024))
}
return data, nil
}
if raw == "-" {
if dataframeStdinCache != nil {
return dataframeStdinCache, nil
}
// A process has a single stdin: --dataframe is binary and bypasses the
// common Input resolver, so we have to share the stdin-consumed flag with
// it explicitly. Without this, e.g. `+table-put --dataframe - --styles -`
// would be accepted and one of them would silently see an empty stream.
if rctx.StdinConsumed() {
return nil, common.ValidationErrorf("--dataframe: stdin (-) can only be used by one flag").
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --styles @/path/to/styles.json)")
}
ios := rctx.IO()
if ios == nil || ios.In == nil {
return nil, common.ValidationErrorf("--dataframe: stdin is not available")
}
data, err := readCapped(ios.In)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
return nil, common.ValidationErrorf("--dataframe: read stdin: %v", err).WithCause(err)
}
if len(data) == 0 {
return nil, common.ValidationErrorf("--dataframe: stdin is empty")
}
dataframeStdinCache = data
rctx.MarkStdinConsumed()
return data, nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return nil, common.ValidationErrorf("--dataframe: file input is not available in this context")
}
// Pre-check size via Stat so a multi-GB file is rejected immediately
// instead of being streamed all the way to the cap.
if info, statErr := fio.Stat(path); statErr == nil && info.Size() > dataframeMaxBytes {
return nil, common.ValidationErrorf(
"--dataframe: file %q is %d MiB, exceeds %d MiB cap",
path, info.Size()/(1024*1024), dataframeMaxBytes/(1024*1024))
}
f, err := fio.Open(path)
if err != nil {
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
defer f.Close()
data, err := readCapped(f)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
return nil, common.ValidationErrorf("--dataframe: %v", err).WithCause(err)
}
if len(data) == 0 {
return nil, common.ValidationErrorf("--dataframe: file %q is empty", path)
}
return data, nil
}
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
// not touched here — parseDataframePayload layers those on from CLI flags.
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
reader, err := ipc.NewFileReader(bytes.NewReader(data))
if err != nil {
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
defer reader.Close()
schema := reader.Schema()
if schema == nil || schema.NumFields() == 0 {
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
ncols := schema.NumFields()
if ncols > dataframeMaxCols {
// Fail fast at the schema layer before allocating per-column slices.
// 200 cols matches the backend's per-sheet hard ceiling — anything past
// that would error on the first set_cell_range anyway.
return tableSheetSpec{}, fmt.Errorf("%d columns exceeds the per-sheet ceiling of %d", ncols, dataframeMaxCols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
cols := make([]tableColumnSpec, ncols)
seen := make(map[string]bool, ncols)
for i := 0; i < ncols; i++ {
f := schema.Field(i)
name := f.Name
if strings.TrimSpace(name) == "" {
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if seen[name] {
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
seen[name] = true
typ, format, err := arrowFieldToTypeFormat(f)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("column %q: %w", name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
}
var rows [][]interface{}
for b := 0; b < reader.NumRecords(); b++ {
rec, err := reader.RecordAt(b)
if err != nil {
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %w", b, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// Reject early during iteration before materializing more rows into the
// [][]interface{} buffer — without this, a 1M-row Arrow file would be
// fully decoded into memory before the writer's per-batch size check
// kicks in.
if int64(len(rows))+rec.NumRows() > int64(dataframeMaxRows) {
rec.Release()
return tableSheetSpec{}, fmt.Errorf("%d rows exceeds the per-sheet ceiling of %d", int64(len(rows))+rec.NumRows(), dataframeMaxRows) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
batchRows, err := arrowRecordToRows(rec, cols)
rec.Release()
if err != nil {
return tableSheetSpec{}, err
}
rows = append(rows, batchRows...)
}
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
}
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
// pair. The field's `number_format` metadata key — when present — sets the
// Excel number_format string verbatim; otherwise sensible defaults are
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
if v, ok := f.Metadata.GetValue("number_format"); ok {
format = strings.TrimSpace(v)
}
switch f.Type.(type) {
case *arrow.StringType, *arrow.LargeStringType:
if format == "" {
format = "@"
}
return "string", format, nil
case *arrow.BooleanType:
return "bool", format, nil
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
if format == "" {
format = "yyyy-mm-dd"
}
return "date", format, nil
}
if isArrowNumericType(f.Type) {
return "number", format, nil
}
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name()) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
func isArrowNumericType(t arrow.DataType) bool {
switch t.ID() {
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
return true
}
return false
}
// arrowRecordToRows transposes one column-batch into row-major
// [][]interface{} matched to `cols`. Cells are stamped with the same value
// shapes buildTypedCell expects from the JSON path: nil for nulls,
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
// dates/timestamps, bool for booleans, string for strings.
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
if int(rec.NumCols()) != len(cols) {
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols)) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
nrows := int(rec.NumRows())
rows := make([][]interface{}, nrows)
for r := range rows {
rows[r] = make([]interface{}, len(cols))
}
for c := range cols {
arr := rec.Column(c)
for r := 0; r < nrows; r++ {
if arr.IsNull(r) {
continue
}
v, err := arrowCellValue(arr, r)
if err != nil {
return nil, fmt.Errorf("row %d column %q: %w", r, cols[c].Name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
rows[r][c] = v
}
}
return rows, nil
}
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
switch a := arr.(type) {
case *array.String:
return a.Value(i), nil
case *array.LargeString:
return a.Value(i), nil
case *array.Boolean:
return a.Value(i), nil
case *array.Int8:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int16:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int32:
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
case *array.Int64:
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
case *array.Uint8:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint16:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint32:
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
case *array.Uint64:
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
case *array.Float16:
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
case *array.Float32:
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
case *array.Float64:
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
case *array.Date32:
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
// in UTC so timezone offset can't flip the calendar date.
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
return t.Format("2006-01-02"), nil
case *array.Date64:
t := time.UnixMilli(int64(a.Value(i))).UTC()
return t.Format("2006-01-02"), nil
case *array.Timestamp:
ts := int64(a.Value(i))
unit := a.DataType().(*arrow.TimestampType).Unit
var t time.Time
switch unit {
case arrow.Second:
t = time.Unix(ts, 0).UTC()
case arrow.Millisecond:
t = time.UnixMilli(ts).UTC()
case arrow.Microsecond:
t = time.UnixMicro(ts).UTC()
case arrow.Nanosecond:
t = time.Unix(0, ts).UTC()
default:
return nil, fmt.Errorf("unsupported timestamp unit %v", unit) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return t.Format("2006-01-02"), nil
}
return nil, fmt.Errorf("unsupported Arrow array %T", arr) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
//
// +table-get's binary read-back: encode one sheet's typed read-back as an
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
// is unchanged; --dataframe-out swaps the encoder for callers that already
// have pandas / pyarrow in their pipeline.
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
// file blob. Internal column types are recovered from `dtypes` (the wire
// proxy for the typed protocol), and per-column `number_format` rides through
// as Arrow field metadata so the file feeds straight back into
// `+table-put --dataframe`.
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
columns, _ := sheet["columns"].([]interface{})
if len(columns) == 0 {
return nil, fmt.Errorf("sheet has no columns") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
dtypes, _ := sheet["dtypes"].(map[string]interface{})
formats, _ := sheet["formats"].(map[string]interface{})
// `data` arrives as either []interface{} (when the sheet came through a
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
// readSheetAsSpec directly emits in production). Accept both — anything
// else falls through to a zero-row table.
var rawData [][]interface{}
switch d := sheet["data"].(type) {
case [][]interface{}:
rawData = d
case []interface{}:
rawData = make([][]interface{}, len(d))
for i, r := range d {
rawData[i], _ = r.([]interface{})
}
}
ncols := len(columns)
colNames := make([]string, ncols)
colTypes := make([]string, ncols)
fields := make([]arrow.Field, ncols)
for i, c := range columns {
name, _ := c.(string)
if name == "" {
return nil, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
colNames[i] = name
dt, _ := dtypes[name].(string)
colTypes[i] = dtypeToInternalType(dt)
var meta arrow.Metadata
if formats != nil {
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
}
}
fields[i] = arrow.Field{
Name: name,
Type: internalTypeToArrowType(colTypes[i]),
Nullable: true,
Metadata: meta,
}
}
schema := arrow.NewSchema(fields, nil)
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
for r, row := range rawData {
if len(row) != ncols {
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
for c := 0; c < ncols; c++ {
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
return nil, fmt.Errorf("row %d column %q: %w", r, colNames[c], err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
}
}
rec := rb.NewRecord()
defer rec.Release()
var buf bytesWriterSeeker
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
return nil, fmt.Errorf("ipc.NewFileWriter: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if err := w.Write(rec); err != nil {
return nil, fmt.Errorf("write record: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return buf.buf, nil
}
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
// internal column type from the wire-level dtype string. Unknown / object
// falls back to string (lossless: every cell is already typed as such).
func dtypeToInternalType(dtype string) string {
switch strings.ToLower(strings.TrimSpace(dtype)) {
case "float64", "float32", "int64", "int32", "int16", "int8",
"uint64", "uint32", "uint16", "uint8":
return "number"
case "bool", "boolean":
return "bool"
}
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
return "date"
}
return "string"
}
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
// internal column type to the Arrow data type the encoder builds a column
// with. Numbers go to float64 because +table-get can't tell int from float
// from a number_format alone — float64 covers both losslessly for the cell
// ranges Lark Sheets accepts.
func internalTypeToArrowType(typ string) arrow.DataType {
switch typ {
case "number":
return arrow.PrimitiveTypes.Float64
case "date":
return arrow.FixedWidthTypes.Date32
case "bool":
return arrow.FixedWidthTypes.Boolean
}
return arrow.BinaryTypes.String
}
// appendArrowCell stamps one cell into its column builder. Cell shape matches
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
// nil for empty. Anything off-shape errors so the caller doesn't silently
// emit nulls for malformed data.
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
if v == nil {
b.AppendNull()
return nil
}
switch typ {
case "string":
s, ok := v.(string)
if !ok {
return fmt.Errorf("string expects string value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.StringBuilder).Append(s)
case "number":
f, err := arrowNumber(v)
if err != nil {
return err
}
b.(*array.Float64Builder).Append(f)
case "date":
s, ok := v.(string)
if !ok {
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
if err != nil {
return fmt.Errorf("date parse %q: %w", s, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
case "bool":
bb, ok := v.(bool)
if !ok {
return fmt.Errorf("bool expects bool, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
b.(*array.BooleanBuilder).Append(bb)
default:
return fmt.Errorf("unsupported internal type %q", typ) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return nil
}
// arrowNumber converts the number cell shape readSheetAsSpec emits
// (json.Number) plus the float fallback to float64 for the Arrow builder.
func arrowNumber(v interface{}) (float64, error) {
switch n := v.(type) {
case json.Number:
f, err := n.Float64()
if err != nil {
return 0, fmt.Errorf("number parse %q: %w", n.String(), err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return f, nil
case float64:
return n, nil
}
return 0, fmt.Errorf("number expects numeric value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
// --dataframe-out's stdout path zero-IO and stays straightforward.
type bytesWriterSeeker struct {
buf []byte
pos int64
}
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
end := w.pos + int64(len(p))
if end > int64(len(w.buf)) {
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
}
n := copy(w.buf[w.pos:], p)
w.pos = end
return n, nil
}
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
switch whence {
case io.SeekStart:
w.pos = offset
case io.SeekCurrent:
w.pos += offset
case io.SeekEnd:
w.pos = int64(len(w.buf)) + offset
default:
return 0, fmt.Errorf("unknown whence %d", whence) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
}
return w.pos, nil
}
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
if raw == "-" {
out := rctx.IO()
if out == nil || out.Out == nil {
return common.ValidationErrorf("--dataframe-out: stdout is not available")
}
if _, err := out.Out.Write(data); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write stdout").WithCause(err)
}
return nil
}
path := strings.TrimPrefix(raw, "@")
fio := rctx.FileIO()
if fio == nil {
return common.ValidationErrorf("--dataframe-out: file output is not available in this context")
}
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
// readDataframeBytes hits on the input side) and writes atomically, so we
// don't need an extra ValidatePath call here.
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write %q", path).WithCause(err)
}
return nil
}

View File

@@ -0,0 +1,421 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/apache/arrow/go/v17/arrow/ipc"
"github.com/apache/arrow/go/v17/arrow/memory"
"github.com/larksuite/cli/shortcuts/common"
)
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
// Used by the round-trip tests below to stand in for what
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
// depending on a pandas-shaped fixture file.
//
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
// offset), so we write to a temp file and read the bytes back — simpler than
// re-implementing a seekable in-memory buffer.
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
t.Helper()
mem := memory.NewGoAllocator()
rb := array.NewRecordBuilder(mem, schema)
defer rb.Release()
build(rb)
rec := rb.NewRecord()
defer rec.Release()
path := filepath.Join(t.TempDir(), "df.arrow")
f, err := os.Create(path)
if err != nil {
t.Fatalf("create temp arrow file: %v", err)
}
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
if err != nil {
f.Close()
t.Fatalf("ipc.NewFileWriter: %v", err)
}
if err := w.Write(rec); err != nil {
t.Fatalf("write record: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close file: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp arrow file: %v", err)
}
return data
}
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
// (type, format) mapping and the per-cell value shape that buildTypedCell
// expects: number cells are json.Number (precision-preserving), date cells
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
// quietly regress any one family.
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "name", Type: arrow.BinaryTypes.String},
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
[]string{"number_format"}, []string{"$#,##0.00"},
)},
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
}, nil)
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
})
spec, err := decodeArrowToSheet(buf, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
if spec.Name != "S1" {
t.Errorf("sheet name = %q, want S1", spec.Name)
}
if len(spec.Columns) != 5 {
t.Fatalf("got %d columns, want 5", len(spec.Columns))
}
want := []struct{ typ, format string }{
{"string", "@"},
{"number", ""},
{"number", "$#,##0.00"},
{"bool", ""},
{"date", "yyyy-mm-dd"},
}
for i, w := range want {
if spec.Columns[i].Type != w.typ {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
}
if spec.Columns[i].Format != w.format {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
// Row 0: every field present, types match what buildTypedCell will accept.
row0 := spec.Rows[0]
if row0[0] != "alice" {
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
}
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
}
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
}
if row0[3] != true {
t.Errorf("row0[active] = %#v, want true", row0[3])
}
if row0[4] != "2024-01-15" {
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
}
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
// so buildTypedCell paints an empty cell that still carries number_format.
row1 := spec.Rows[1]
for _, c := range []int{0, 1, 2} {
if row1[c] != nil {
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
}
}
if row1[3] != false {
t.Errorf("row1[active] = %#v, want false", row1[3])
}
if row1[4] != "2024-02-02" {
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
}
}
// TestDataframe_Timestamp pins the timestamp → date conversion for the
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
// landing — guard against TZ drift from the wrong unit pick.
func TestDataframe_Timestamp(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
}, nil)
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
})
spec, err := decodeArrowToSheet(buf, "S")
if err != nil {
t.Fatal(err)
}
if spec.Columns[0].Type != "date" {
t.Errorf("type = %q, want date", spec.Columns[0].Type)
}
if got := spec.Rows[0][0]; got != "2024-06-12" {
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
}
}
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
// a 0-column "DataFrame" would write a header-less, data-less block that
// validates as "writer ran successfully" but produces nothing — the test ties
// that off as an explicit error rather than letting it slip through.
func TestDataframe_EmptySchema(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema(nil, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "no fields") {
t.Errorf("err = %v, want 'no fields' error", err)
}
}
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
// time. Validate already rejects duplicate column names for the JSON path;
// the Arrow path mirrors that so the error surfaces with the same shape.
func TestDataframe_DuplicateColumn(t *testing.T) {
t.Parallel()
schema := arrow.NewSchema([]arrow.Field{
{Name: "x", Type: arrow.BinaryTypes.String},
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
}, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
b.Field(0).(*array.StringBuilder).Append("")
b.Field(1).(*array.Int64Builder).Append(0)
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("err = %v, want duplicate-column error", err)
}
}
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
// pandas df.to_feather so users see what producer is expected without having
// to grep the docs.
func TestDataframe_BadBytes(t *testing.T) {
t.Parallel()
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
if err == nil || !strings.Contains(err.Error(), "Arrow") {
t.Errorf("err = %v, want Arrow-decode error", err)
}
}
// TestDecodeArrowToSheet_RejectsTooManyColumns guards the per-sheet column cap.
// Without it a wide Arrow file (e.g. 201+ columns) would allocate a long
// tableColumnSpec slice + decode every batch before the backend's 200-column
// per-sheet ceiling rejected the first set_cell_range — wasting both CPU and
// memory before the failure surfaces. Cap matches the backend hard ceiling.
func TestDecodeArrowToSheet_RejectsTooManyColumns(t *testing.T) {
t.Parallel()
fields := make([]arrow.Field, dataframeMaxCols+1)
for i := range fields {
fields[i] = arrow.Field{Name: "c" + strings.TrimSpace(string(rune('0'+i%10))) + "_" + strings.Repeat("x", i/10+1), Type: arrow.BinaryTypes.String}
}
schema := arrow.NewSchema(fields, nil)
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
for i := range fields {
b.Field(i).(*array.StringBuilder).Append("")
}
})
_, err := decodeArrowToSheet(buf, "S")
if err == nil || !strings.Contains(err.Error(), "exceeds the per-sheet ceiling") {
t.Errorf("err = %v, want column-cap error", err)
}
}
// TestReadDataframeBytes_RejectsSecondStdinConsumer covers the case where another
// flag (e.g. --styles) has already consumed stdin via the common Input resolver:
// since --dataframe bypasses that resolver, the only thing keeping the two from
// racing for an empty stream is the explicit StdinConsumed() check in
// readDataframeBytes. Without that check, fangshuyu's report holds — both flags
// silently accept '-' and one of them sees empty bytes downstream.
func TestReadDataframeBytes_RejectsSecondStdinConsumer(t *testing.T) {
// process-wide cache must be reset so the test isn't served from a prior run.
saved := dataframeStdinCache
dataframeStdinCache = nil
t.Cleanup(func() { dataframeStdinCache = saved })
rctx := &common.RuntimeContext{}
rctx.MarkStdinConsumed()
_, err := readDataframeBytes(rctx, "-")
requireValidation(t, err, "stdin (-) can only be used by one flag")
}
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
// own decoder: build a +table-get-shaped sheet map (the same one
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
// decoder, and require the column types / formats / row values to match. If
// any encoder choice drifts from what the decoder expects, the round-trip
// breaks here long before a real put → get round-trip in production would.
func TestDataframe_EncodeRoundTrip(t *testing.T) {
t.Parallel()
sheet := map[string]interface{}{
"name": "S1",
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
"dtypes": map[string]interface{}{
"name": "object",
"qty": "float64",
"price": "float64",
"active": "bool",
"ts": "datetime64[ns]",
},
"formats": map[string]interface{}{
// `@` is the writer convention for string columns; readSheetAsSpec
// strips it via isTextNumberFormat, so an Arrow file built from a
// real read won't carry @ either. Keep it absent here to mirror
// the production wire shape.
"price": "$#,##0.00",
},
"data": []interface{}{
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
},
}
blob, err := encodeSheetMapToArrowIPC(sheet)
if err != nil {
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
}
spec, err := decodeArrowToSheet(blob, "S1")
if err != nil {
t.Fatalf("decodeArrowToSheet: %v", err)
}
wantTypes := []string{"string", "number", "number", "bool", "date"}
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
if len(spec.Columns) != len(wantTypes) {
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
}
for i, w := range wantTypes {
if spec.Columns[i].Type != w {
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
}
if spec.Columns[i].Format != wantFormats[i] {
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
}
}
if len(spec.Rows) != 2 {
t.Fatalf("got %d rows, want 2", len(spec.Rows))
}
if spec.Rows[0][0] != "alice" {
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
}
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
}
if spec.Rows[0][3] != true {
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
}
if spec.Rows[0][4] != "2024-01-15" {
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
}
// qty is null on row1, must come back as nil (not a zero-valued
// json.Number that would later round-trip as 0).
if spec.Rows[1][1] != nil {
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
}
}
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
// output — early on the production shape silently fell through the
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
t.Parallel()
base := func(data interface{}) map[string]interface{} {
return map[string]interface{}{
"name": "S",
"columns": []interface{}{"city"},
"dtypes": map[string]interface{}{"city": "object"},
"data": data,
}
}
for label, data := range map[string]interface{}{
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
} {
blob, err := encodeSheetMapToArrowIPC(base(data))
if err != nil {
t.Errorf("%s: encode: %v", label, err)
continue
}
spec, err := decodeArrowToSheet(blob, "S")
if err != nil {
t.Errorf("%s: decode: %v", label, err)
continue
}
if len(spec.Rows) != 2 {
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
}
}
}
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
// dtype families +table-get emits today plus the safe fallback for unknown
// labels (string, lossless).
func TestDataframe_DtypeToInternalType(t *testing.T) {
t.Parallel()
cases := map[string]string{
"float64": "number",
"int64": "number",
"Int64": "number",
"bool": "bool",
"boolean": "bool",
"datetime64[ns]": "date",
"datetime64[ms]": "date",
"object": "string",
"": "string",
"weird-new-dtype": "string",
}
for in, want := range cases {
if got := dtypeToInternalType(in); got != want {
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
}
}
}
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
// offset: write some bytes, seek back to the middle, overwrite, end up with
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
func TestDataframe_BytesWriterSeeker(t *testing.T) {
t.Parallel()
var w bytesWriterSeeker
if _, err := w.Write([]byte("hello world")); err != nil {
t.Fatal(err)
}
if _, err := w.Seek(6, 0); err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("WORLD")); err != nil {
t.Fatal(err)
}
if got := string(w.buf); got != "hello WORLD" {
t.Errorf("buf = %q, want \"hello WORLD\"", got)
}
}

View File

@@ -87,18 +87,29 @@ var TablePut = common.Shortcut{
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).",
"Two equivalent producers: --sheets (multi-sheet JSON, the pandas-split convention) or --dataframe (single-sheet Arrow IPC binary, what `df.to_feather()` writes). Mutually exclusive; pick by what your producer already emits.",
"--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
// resolveTablePayload picks between --sheets (JSON, multi-sheet) and
// --dataframe (Arrow IPC, single-sheet), enforces XOR, and returns 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")
dfGiven := rctx.Changed("dataframe") && strings.TrimSpace(rctx.Str("dataframe")) != ""
if sheetsGiven && dfGiven {
return nil, common.ValidationErrorf("--sheets and --dataframe are mutually exclusive")
}
if !sheetsGiven && !dfGiven {
// Mirror the original "--sheets is required" message but list both
// alternatives so users discover the binary entry from the error.
return nil, common.ValidationErrorf("one of --sheets or --dataframe is required")
}
if dfGiven {
return parseDataframePayload(rctx)
}
return parseTablePutPayload(rctx)
}
@@ -1033,6 +1044,15 @@ var TableGet = common.Shortcut{
if strings.TrimSpace(runtime.Str("sheet-id")) != "" && strings.TrimSpace(runtime.Str("sheet-name")) != "" {
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive")
}
// --dataframe-out is Arrow IPC, which carries one schema per file — a
// whole-workbook read can't ride that shape. Surface the constraint
// before we round-trip to the API instead of after the read fails to
// encode.
if strings.TrimSpace(runtime.Str("dataframe-out")) != "" {
if strings.TrimSpace(runtime.Str("sheet-id")) == "" && strings.TrimSpace(runtime.Str("sheet-name")) == "" {
return common.ValidationErrorf("--dataframe-out requires --sheet-id or --sheet-name (single-sheet only); for the whole workbook, drop --dataframe-out and use the default JSON output")
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -1084,12 +1104,38 @@ var TableGet = common.Shortcut{
}
sheets = append(sheets, spec)
}
// Arrow IPC binary branch: Validate already guards single-sheet so the
// sheets slice has exactly one entry here. Stdout mode owns stdout for
// the binary stream — no envelope, raw Arrow only. File mode writes
// the Arrow blob to disk and still emits the lark-cli JSON envelope
// (output_path / bytes / sheet_name) so scripted callers can detect
// success the same way they do for every other shortcut.
if dfOut := strings.TrimSpace(runtime.Str("dataframe-out")); dfOut != "" {
spec, _ := sheets[0].(map[string]interface{})
data, err := encodeSheetMapToArrowIPC(spec)
if err != nil {
return common.ValidationErrorf("--dataframe-out: encode arrow: %v", err).WithCause(err)
}
if err := writeDataframeOut(runtime, dfOut, data); err != nil {
return err
}
if dfOut == "-" {
return nil
}
runtime.Out(map[string]interface{}{
"output_path": strings.TrimPrefix(dfOut, "@"),
"bytes": len(data),
"sheet_name": spec["name"],
}, nil)
return nil
}
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).",
"For a pandas round-trip, use --dataframe-out (single sheet, Arrow IPC / Feather v2) — `@./x.arrow` writes a file, `-` streams binary to stdout for `pd.read_feather(BytesIO(stdout))`. Multi-sheet reads stay on the JSON path.",
},
}

View File

@@ -574,13 +574,18 @@ var WorkbookCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("title")) == "" {
return common.ValidationErrorf("--title is required")
}
// --sheets (typed JSON) is the typed data entry, mutually exclusive
// with the untyped --values. Gating on Changed (not just non-empty)
// catches an explicitly-given but empty payload as an error instead
// of letting it fall through to creating an empty workbook.
// --sheets (typed JSON) and --dataframe (typed Arrow IPC) are two
// alternative typed data entries; both are mutually exclusive with
// the untyped --values. Gating on Changed (not just non-empty) catches
// an explicitly-given but empty payload as an error instead of letting
// it fall through to creating an empty workbook.
sheetsGiven := runtime.Changed("sheets")
if sheetsGiven && runtime.Str("values") != "" {
return common.ValidationErrorf("--values is mutually exclusive with --sheets")
dfGiven := runtime.Changed("dataframe")
if sheetsGiven && dfGiven {
return common.ValidationErrorf("--sheets and --dataframe are mutually exclusive")
}
if (sheetsGiven || dfGiven) && runtime.Str("values") != "" {
return common.ValidationErrorf("--values is mutually exclusive with --sheets/--dataframe")
}
if sheetsGiven {
if strings.TrimSpace(runtime.Str("sheets")) == "" {
@@ -593,6 +598,17 @@ var WorkbookCreate = common.Shortcut{
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
return err
}
if dfGiven {
if strings.TrimSpace(runtime.Str("dataframe")) == "" {
return common.ValidationErrorf("--dataframe was given but resolved to empty; pass a path to an Arrow IPC file, or drop --dataframe to create an empty workbook")
}
payload, err := parseDataframePayload(runtime)
if err != nil {
return err
}
_, err = parseWorkbookCreateSheetStyles(runtime, payload)
return err
}
// Untyped --values path: parse (and validate) --styles as a single sheet
// style item, then synthesize --values into a type-less typed payload —
// the same construction buildValuesPayload runs at execute time, so any
@@ -680,11 +696,11 @@ var WorkbookCreate = common.Shortcut{
if payload != nil {
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
if err != nil {
return workbookCreatedButFillFailed(runtime, token, "resolving its default sheet for the write failed", err)
return workbookCreatedButFillFailed(token, "resolving its default sheet for the write failed", err)
}
written, err := writeTypedSheets(ctx, runtime, token, payload, firstSheetID, sheetStyles)
if err != nil {
return workbookCreatedButFillFailed(runtime, token, "initial fill failed", err)
return workbookCreatedButFillFailed(token, "initial fill failed", err)
}
result["sheets"] = written
} else if styles := sheetStyles.styleFor(0); styles != nil {
@@ -696,10 +712,10 @@ var WorkbookCreate = common.Shortcut{
// here.
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
if err != nil {
return workbookCreatedButFillFailed(runtime, token, "resolving its default sheet for the write failed", err)
return workbookCreatedButFillFailed(token, "resolving its default sheet for the write failed", err)
}
if err := applyWorkbookCreateVisualOps(ctx, runtime, token, firstSheetID, styles); err != nil {
return workbookCreatedButFillFailed(runtime, token, "applying visual styles failed", err)
return workbookCreatedButFillFailed(token, "applying visual styles failed", err)
}
}
runtime.Out(result, nil)
@@ -711,35 +727,15 @@ var WorkbookCreate = common.Shortcut{
},
}
// workbookCreatedButFillFailed reports a workbook-create where the spreadsheet
// POST succeeded but the follow-up initial fill did not. It is the same
// partial-state shape as +table-put's multi-sheet half-write: stdout carries an
// ok:false envelope with the new spreadsheet_token (so the caller can retry the
// fill via +cells-set / +csv-put, or delete the orphan), and the process exits
// with the partial-failure signal — keeping a single sheets-domain contract for
// "the side effect landed but the follow-up didn't" instead of two (this used to
// surface as a typed failed_precondition on stderr, which agents couldn't tell
// apart from a plain validation refusal). The underlying cause's typed shape is
// flattened into a structured `cause` field so the inner subtype / category /
// message stays diagnosable from the JSON envelope alone.
func workbookCreatedButFillFailed(runtime *common.RuntimeContext, token, reason string, cause error) error {
data := map[string]interface{}{
"spreadsheet_token": token,
"reason": fmt.Sprintf("spreadsheet %s created but %s", token, reason),
"hint": "the spreadsheet exists; retry the fill with the returned spreadsheet_token (+cells-set / +csv-put), or delete it",
}
if cause != nil {
if p, ok := errs.ProblemOf(cause); ok {
data["cause"] = map[string]interface{}{
"category": string(p.Category),
"subtype": string(p.Subtype),
"message": p.Message,
}
} else {
data["cause"] = map[string]interface{}{"message": cause.Error()}
}
}
return runtime.OutPartialFailure(data, nil)
// workbookCreatedButFillFailed builds a structured partial-success error for the
// window where the spreadsheet POST succeeded but the follow-up initial fill did
// not. The new spreadsheet_token is surfaced in the message and the underlying
// failure is preserved as the cause so callers can retry the fill
// (+cells-set / +csv-put) or delete the orphan, rather than orphaning the workbook.
func workbookCreatedButFillFailed(token, reason string, cause error) error {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "spreadsheet %s created but %s", token, reason).
WithCause(cause).
WithHint("the spreadsheet exists; retry the fill with the returned spreadsheet_token (+cells-set / +csv-put), or delete it")
}
// valuesSheetName is the synthesized sheet name for the untyped --values path.
@@ -765,6 +761,17 @@ func workbookCreateData(runtime *common.RuntimeContext) (*tablePayload, *workboo
}
return payload, styles, nil
}
if runtime.Changed("dataframe") {
payload, err := parseDataframePayload(runtime)
if err != nil {
return nil, nil, err
}
styles, err := parseWorkbookCreateSheetStyles(runtime, payload)
if err != nil {
return nil, nil, err
}
return payload, styles, nil
}
styles, err := parseValuesSheetStyles(runtime)
if err != nil {
return nil, nil, err

View File

@@ -242,6 +242,7 @@ func Shortcuts() []common.Shortcut {
GetMyTasks,
GetRelatedTasks,
SearchTask,
SubscribeTaskEvent,
UploadAttachmentTask,
CreateTasklist,
SearchTasklist,

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"github.com/larksuite/cli/shortcuts/common"
)
var SubscribeTaskEvent = common.Shortcut{
Service: "task",
Command: "+subscribe-event",
Description: "subscribe to task events",
Risk: "write",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/task/v2/task_v2/task_subscription").
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := map[string]interface{}{"user_id_type": "open_id"}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/task_v2/task_subscription", params, nil); err != nil {
return err
}
outData := map[string]interface{}{"ok": true}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
})
return nil
},
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSubscribeTaskEvent(t *testing.T) {
tests := []struct {
name string
mode string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "execute json (user identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute json (bot identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute api error",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 401,
"msg": "Unauthorized",
"error": map[string]interface{}{
"log_id": "test-log-id",
},
},
})
},
wantErr: true,
wantParts: []string{"Unauthorized"},
},
{
name: "dry run",
mode: "dryrun",
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.mode {
case "execute":
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
if tt.register != nil {
tt.register(reg)
}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
out := err.Error()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("error missing %q: %s", want, out)
}
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
case "dryrun":
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
}
})
}
}
// TestSubscribeTaskEvent_MalformedResponse covers the parse-response arm: a 200
// with an unparseable body surfaces a typed internal invalid_response error
// (exit 5).
func TestSubscribeTaskEvent_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Status: 200,
RawBody: []byte("{not-json"),
})
args := []string{"+subscribe-event", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}

View File

@@ -5,7 +5,6 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -22,54 +21,40 @@ import (
)
const (
// WhiteboardQueryAsImage exports a whiteboard preview image.
WhiteboardQueryAsImage = "image"
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
WhiteboardQueryAsSvg = "svg"
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
WhiteboardQueryAsCode = "code"
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
WhiteboardQueryAsRaw = "raw"
WhiteboardQueryAsCode = "code"
WhiteboardQueryAsRaw = "raw"
)
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
type SyntaxType int
const (
// SyntaxTypePlantUML marks PlantUML code blocks.
SyntaxTypePlantUML SyntaxType = 1
// SyntaxTypeMermaid marks Mermaid code blocks.
SyntaxTypeMermaid SyntaxType = 2
SyntaxTypeMermaid SyntaxType = 2
)
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
var SyntaxTypeNameMap = map[SyntaxType]string{
SyntaxTypePlantUML: "plantuml",
SyntaxTypeMermaid: "mermaid",
}
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
var SyntaxTypeExtensionMap = map[SyntaxType]string{
SyntaxTypePlantUML: ".puml",
SyntaxTypeMermaid: ".mmd",
}
// String returns the CLI-facing name for the syntax type.
func (s SyntaxType) String() string {
return SyntaxTypeNameMap[s]
}
// ExtensionName returns the default file extension for the syntax type.
func (s SyntaxType) ExtensionName() string {
return SyntaxTypeExtensionMap[s]
}
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
func (s SyntaxType) IsValid() bool {
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
}
// WhiteboardQuery registers the `whiteboard +query` shortcut.
var WhiteboardQuery = common.Shortcut{
Service: "whiteboard",
Command: "+query",
@@ -79,8 +64,8 @@ var WhiteboardQuery = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
},
HasFormat: true,
@@ -101,8 +86,8 @@ var WhiteboardQuery = common.Shortcut{
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
}
return nil
},
@@ -122,13 +107,8 @@ var WhiteboardQuery = common.Shortcut{
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardQueryAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -139,105 +119,17 @@ var WhiteboardQuery = common.Shortcut{
switch as {
case WhiteboardQueryAsImage:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardQueryAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardQueryAsCode:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
}
},
}
// exportReq defines the request body for whiteboard export APIs.
type exportReq struct {
ExportType string `json:"export_type"`
}
// exportResp models the whiteboard export response envelope.
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
if exportData.Code != 0 {
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
}
} else if resp.StatusCode == http.StatusOK {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
}
if resp.StatusCode != http.StatusOK {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
@@ -475,8 +367,6 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
switch ext {
case ".png":
contentType = "image/png"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":

View File

@@ -6,8 +6,6 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -15,7 +13,6 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -23,7 +20,6 @@ import (
"github.com/spf13/cobra"
)
// TestSyntaxType verifies syntax names, extensions, and validity checks.
func TestSyntaxType(t *testing.T) {
t.Parallel()
@@ -79,7 +75,6 @@ func TestSyntaxType(t *testing.T) {
}
}
// TestWhiteboardQuery_Validate verifies query flag validation for supported output modes.
func TestWhiteboardQuery_Validate(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
@@ -204,9 +199,6 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
})
}
}
@@ -240,7 +232,6 @@ func TestExportWhiteboardPreview_HTTPError(t *testing.T) {
}
}
// TestExportWhiteboardPreview_HTTPNotFoundIsAPIError verifies 404 preview downloads surface as typed API errors.
func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
@@ -264,7 +255,6 @@ func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
}
}
// TestWhiteboardQuery_DryRun verifies dry-run output for the supported query modes.
func TestWhiteboardQuery_DryRun(t *testing.T) {
t.Parallel()
@@ -317,64 +307,6 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
}
}
// TestWhiteboardQuery_DryRun_InvalidOutputAs verifies dry-run guidance for unsupported output modes.
func TestWhiteboardQuery_DryRun_InvalidOutputAs(t *testing.T) {
t.Parallel()
ctx := context.Background()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "invalid",
}, nil)
dryRun := WhiteboardQuery.DryRun(ctx, rt)
if dryRun == nil {
t.Fatal("WhiteboardQuery.DryRun() returned nil")
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if !strings.Contains(string(data), "image | svg | code | raw") {
t.Fatalf("dry run desc = %s, want invalid output_as guidance", string(data))
}
}
// TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError verifies invalid output modes return typed validation errors.
func TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError(t *testing.T) {
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "invalid",
}, nil)
err := WhiteboardQuery.Execute(context.Background(), rt)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--output_as" {
t.Errorf("Param = %q, want %q", ve.Param, "--output_as")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
}
// TestWhiteboardQuery_ShortcutRegistration verifies the whiteboard query shortcut metadata.
func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
t.Parallel()
@@ -393,7 +325,6 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
}
}
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
func TestSaveOutputFile(t *testing.T) {
t.Parallel()
@@ -545,7 +476,6 @@ func TestSaveOutputFile(t *testing.T) {
}
}
// TestSaveOutputFile_InvalidFinalPathTypedError verifies invalid save paths return typed validation errors.
func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
chdirTemp(t)
@@ -561,19 +491,6 @@ func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--output" {
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("expected path-validation cause to be preserved, err=%v", err)
}
}
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
@@ -608,7 +525,6 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
return err
}
// TestWhiteboardQueryExecute_AsRaw verifies raw query execution emits the raw node payload.
func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -637,7 +553,6 @@ func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
}
}
// TestWhiteboardQueryExecute_AsCode verifies code query execution emits extracted diagram source.
func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
@@ -668,7 +583,6 @@ func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
}
}
// TestExportWhiteboardCode_EmptyNodes verifies code export handles empty whiteboards.
func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -691,7 +605,6 @@ func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
}
}
// TestExportWhiteboardCode_NoCodeBlocks verifies code export reports whiteboards without code blocks.
func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -716,7 +629,6 @@ func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
}
}
// TestExportWhiteboardCode_InvalidSyntaxType verifies unknown syntax types are rejected.
func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -746,7 +658,6 @@ func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
}
}
// TestExportWhiteboardCode_MultipleCodeBlocks verifies multiple code blocks are exported together.
func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -786,7 +697,6 @@ func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
}
}
// TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput verifies direct PlantUML output for a single code block.
func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -820,7 +730,6 @@ func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
}
}
// TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput verifies direct Mermaid output for a single code block.
func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -854,7 +763,6 @@ func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
}
}
// TestExportWhiteboardPreview verifies preview downloads can be written to disk.
func TestExportWhiteboardPreview(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -883,7 +791,6 @@ func TestExportWhiteboardPreview(t *testing.T) {
}
}
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -906,7 +813,6 @@ func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
}
}
// TestFetchWhiteboardNodes_APIError verifies node fetch failures preserve typed API errors.
func TestFetchWhiteboardNodes_APIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -936,7 +842,6 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
}
}
// TestFetchWhiteboardNodes_InvalidResponseTypedError verifies malformed node responses become typed invalid-response errors.
func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
tests := []struct {
name string
@@ -996,482 +901,6 @@ func TestFetchWhiteboardNodes_MissingNodesIsEmpty(t *testing.T) {
}
}
// TestExportWhiteboardSvg_DirectOutput verifies SVG export is printed when no output path is provided.
func TestExportWhiteboardSvg_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg", "--output_as", "svg"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(stdout.String(), "svg_content") {
t.Fatalf("stdout missing svg_content key: %s", stdout.String())
}
}
// TestExportWhiteboardSvg_SaveToFile verifies SVG export is written to the requested file.
func TestExportWhiteboardSvg_SaveToFile(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-file", "--output_as", "svg", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data, err := os.ReadFile("output.svg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != svgContent {
t.Fatalf("svg content = %q, want %q", string(data), svgContent)
}
}
// TestExportWhiteboardSvg_PrettyOutput verifies pretty output includes inline SVG content.
func TestExportWhiteboardSvg_PrettyOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0L10 10"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-pretty/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-pretty", "--output_as", "svg", "--format", "pretty"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, svgContent) {
t.Fatalf("stdout = %q, want svg content", got)
}
}
// TestExportWhiteboardSvg_SaveToFile_PrettyOutput verifies pretty output reports the saved SVG path and size.
func TestExportWhiteboardSvg_SaveToFile_PrettyOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="60" cy="40" rx="50" ry="30"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file-pretty/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-file-pretty", "--output_as", "svg", "--output", "output", "--overwrite", "--format", "pretty"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "SVG saved to output.svg") || !strings.Contains(got, "File size:") {
t.Fatalf("stdout = %q, want save summary", got)
}
data, err := os.ReadFile("output.svg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != svgContent {
t.Fatalf("svg content = %q, want %q", string(data), svgContent)
}
}
// TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite verifies existing SVG outputs require --overwrite.
func TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
if err := os.WriteFile("output.svg", []byte("existing content"), 0644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><line x1="0" y1="0" x2="1" y2="1"/></svg>`
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-existing/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-existing", "--output_as", "svg", "--output", "output"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for existing output without overwrite")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--overwrite" {
t.Errorf("Param = %q, want %q", ve.Param, "--overwrite")
}
}
// TestExportWhiteboardSvg_HTTP5xx verifies plain HTTP 5xx failures are classified as retryable network errors.
func TestExportWhiteboardSvg_HTTP5xx(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx/export",
Status: 502,
RawBody: []byte("bad gateway"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 502")
}
var ne *errs.NetworkError
if !errors.As(err, &ne) {
t.Fatalf("error is not *errs.NetworkError: %T (%v)", err, err)
}
if ne.Subtype != errs.SubtypeNetworkServer {
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkServer)
}
if ne.Code != 502 {
t.Errorf("Code = %d, want 502", ne.Code)
}
if !ne.Retryable {
t.Error("expected Retryable = true")
}
}
// TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError verifies API envelopes take precedence over generic 5xx handling.
func TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx-json/export",
Status: 502,
ContentType: "application/json",
RawBody: []byte(`{"code":99002,"msg":"export task failed"}`),
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx-json", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 502 JSON envelope")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
var ne *errs.NetworkError
if errors.As(err, &ne) {
t.Fatalf("expected JSON envelope to win over HTTP 5xx fallback, got *errs.NetworkError: %v", err)
}
if apiErr.Subtype != errs.SubtypeUnknown {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
}
if apiErr.Code != 99002 {
t.Errorf("Code = %d, want 99002", apiErr.Code)
}
}
// TestExportWhiteboardSvg_HTTP4xx verifies plain HTTP 4xx failures are surfaced as API errors.
func TestExportWhiteboardSvg_HTTP4xx(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-403/export",
Status: 403,
RawBody: []byte("forbidden"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-403", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 403")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Subtype != errs.SubtypeUnknown {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
}
if apiErr.Code != 403 {
t.Errorf("Code = %d, want 403", apiErr.Code)
}
}
// TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError verifies not-found envelopes preserve the typed API error classification.
func TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/missing-token-svg/export",
Status: 404,
ContentType: "application/json",
RawBody: []byte(`{"code":99001,"msg":"whiteboard not found"}`),
})
args := []string{"+query", "--whiteboard-token", "missing-token-svg", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 404 JSON envelope")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
}
if apiErr.Code != 99001 {
t.Errorf("Code = %d, want 99001", apiErr.Code)
}
}
// TestExportWhiteboardSvg_HTTPNotFoundPlainText verifies plain-text 404 responses surface as not-found API errors.
func TestExportWhiteboardSvg_HTTPNotFoundPlainText(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/missing-token-svg-plain/export",
Status: 404,
ContentType: "text/plain",
RawBody: []byte("whiteboard not found"),
})
args := []string{"+query", "--whiteboard-token", "missing-token-svg-plain", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for HTTP 404 plain text response")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Subtype != errs.SubtypeNotFound {
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
}
if apiErr.Code != 404 {
t.Errorf("Code = %d, want 404", apiErr.Code)
}
}
// TestExportWhiteboardSvg_InvalidJSON verifies malformed success responses are rejected as invalid responses.
func TestExportWhiteboardSvg_InvalidJSON(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badjson/export",
Status: 200,
RawBody: []byte("not json at all"),
ContentType: "application/json",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-badjson", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
assertInvalidResponse(t, err)
}
// TestExportWhiteboardSvg_InvalidBody200PlainText verifies plain-text 200 responses are rejected as invalid export responses.
func TestExportWhiteboardSvg_InvalidBody200PlainText(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-plain-200/export",
Status: 200,
RawBody: []byte("not json at all"),
ContentType: "text/plain",
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-plain-200", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for plain text success response")
}
assertInvalidResponse(t, err)
}
// TestExportWhiteboardSvg_NonZeroCode verifies non-zero API codes are returned as typed API errors.
func TestExportWhiteboardSvg_NonZeroCode(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-apierr/export",
Body: map[string]interface{}{
"code": 99001,
"msg": "whiteboard not found",
"data": map[string]interface{}{},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-apierr", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for non-zero code")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
}
if apiErr.Code != 99001 {
t.Errorf("Code = %d, want 99001", apiErr.Code)
}
}
// TestExportWhiteboardSvg_InvalidBase64 verifies invalid SVG payload encoding is rejected.
func TestExportWhiteboardSvg_InvalidBase64(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badbase64/export",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"content": "!!!not-valid-base64!!!",
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-badbase64", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatal("expected error for invalid base64")
}
assertInvalidResponse(t, err)
}
// TestWhiteboardQuery_Validate_SvgValid verifies svg is accepted as a valid query output format.
func TestWhiteboardQuery_Validate_SvgValid(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
}, nil)
if err := WhiteboardQuery.Validate(ctx, rt); err != nil {
t.Fatalf("expected svg to be valid, got err=%v", err)
}
}
// TestWhiteboardQuery_DryRun_Svg verifies the svg dry-run request uses the export endpoint and body.
func TestWhiteboardQuery_DryRun_Svg(t *testing.T) {
t.Parallel()
ctx := context.Background()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
}, nil)
dryRun := WhiteboardQuery.DryRun(ctx, rt)
if dryRun == nil {
t.Fatal("DryRun() returned nil for svg")
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(got.API) != 1 {
t.Fatalf("len(api) = %d, want 1", len(got.API))
}
if got.API[0].Method != "POST" {
t.Fatalf("method = %q, want POST", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...-123/export" {
t.Fatalf("url = %q", got.API[0].URL)
}
if got.API[0].Body["export_type"] != "svg" {
t.Fatalf("body = %#v, want export_type=svg", got.API[0].Body)
}
if _, ok := got.API[0].Params["export_type"]; ok {
t.Fatalf("params should not include export_type, got %#v", got.API[0].Params)
}
}
// assertInvalidResponse verifies an error is classified as a typed invalid-response failure.
func assertInvalidResponse(t *testing.T, err error) {
t.Helper()
if err == nil {

View File

@@ -17,21 +17,15 @@ import (
)
const (
// FormatRaw sends raw whiteboard node JSON to the create-nodes API.
FormatRaw = "raw"
// FormatPlantUML sends PlantUML source through the diagram import API.
FormatRaw = "raw"
FormatPlantUML = "plantuml"
// FormatMermaid sends Mermaid source through the diagram import API.
FormatMermaid = "mermaid"
// FormatSVG sends SVG source through the diagram import API.
FormatSVG = "svg"
FormatMermaid = "mermaid"
)
var formatCodeMap = map[string]int{
FormatRaw: 0,
FormatPlantUML: 1,
FormatMermaid: 2,
FormatSVG: 3,
}
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
@@ -41,7 +35,7 @@ var wbUpdateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
{Name: "source", Desc: "Input whiteboard data.", Required: true, Input: []string{common.Stdin, common.File}},
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid | svg. Default is raw.", Required: false},
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid. Default is raw.", Required: false},
}
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -59,8 +53,8 @@ func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error
// 检查 --input_format 标志
format := getFormat(runtime)
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid && format != FormatSVG {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid | svg").WithParam("--input_format")
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid").WithParam("--input_format")
}
return nil
}
@@ -97,7 +91,7 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
Overwrite: overwrite,
}
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
case FormatPlantUML, FormatMermaid, FormatSVG:
case FormatPlantUML, FormatMermaid:
syntaxType := formatCodeMap[format]
reqBody := plantumlCreateReq{
PlantUmlCode: input,
@@ -126,17 +120,15 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
switch format {
case FormatRaw:
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
case FormatPlantUML, FormatMermaid, FormatSVG:
case FormatPlantUML, FormatMermaid:
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported format: %s", format).WithParam("--input_format")
}
}
// WhiteboardUpdateDescription describes the whiteboard update shortcut.
const WhiteboardUpdateDescription = "Update an existing whiteboard in lark document with mermaid, plantuml or whiteboard dsl. refer to lark-whiteboard skill for more details."
// WhiteboardUpdate registers the `whiteboard +update` shortcut.
var WhiteboardUpdate = common.Shortcut{
Service: "whiteboard",
Command: "+update",

View File

@@ -6,7 +6,6 @@ package whiteboard
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -19,7 +18,6 @@ import (
"github.com/spf13/cobra"
)
// TestWhiteboardUpdate_Validate verifies update flag validation for supported input formats.
func TestWhiteboardUpdate_Validate(t *testing.T) {
ctx := context.Background()
@@ -55,15 +53,6 @@ func TestWhiteboardUpdate_Validate(t *testing.T) {
},
wantErr: false,
},
{
name: "valid: svg format",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"input_format": "svg",
"source": "<svg/>",
},
wantErr: false,
},
{
name: "valid: with idempotent-token",
flags: map[string]string{
@@ -128,26 +117,25 @@ func TestWhiteboardUpdate_Validate_TypedErrors(t *testing.T) {
"idempotent-token": "short",
"source": "{}",
}, nil)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token", false)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token")
})
t.Run("bad input_format", func(t *testing.T) {
rt := newTestRuntime(map[string]string{
"whiteboard-token": "t",
"input_format": "png",
"input_format": "svg",
"source": "{}",
}, nil)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format", false)
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format")
})
t.Run("malformed source json", func(t *testing.T) {
_, err, _ := parseWBcliNodes([]byte("not-json"))
assertValidationParam(t, err, "--source", true)
assertValidationParam(t, err, "--source")
})
}
// assertValidationParam verifies a validation error carries the expected flag param.
func assertValidationParam(t *testing.T, err error, wantParam string, wantJSONCause bool) {
func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Helper()
if err == nil {
t.Fatalf("expected error, got nil")
@@ -162,25 +150,8 @@ func assertValidationParam(t *testing.T, err error, wantParam string, wantJSONCa
if ve.Param != wantParam {
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false")
}
if p.Category != errs.CategoryValidation {
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if wantJSONCause {
var syntaxErr *json.SyntaxError
if !errors.As(err, &syntaxErr) {
t.Fatalf("expected json syntax cause to be preserved, err=%v", err)
}
}
}
// TestGetFormat verifies input format defaults and explicit format selection.
func TestGetFormat(t *testing.T) {
t.Parallel()
@@ -209,11 +180,6 @@ func TestGetFormat(t *testing.T) {
flagVal: FormatMermaid,
expected: FormatMermaid,
},
{
name: "svg returns svg",
flagVal: FormatSVG,
expected: FormatSVG,
},
}
for _, tt := range tests {
@@ -227,7 +193,6 @@ func TestGetFormat(t *testing.T) {
}
}
// TestWhiteboardUpdate_ShortcutRegistration verifies the shortcut metadata for update commands.
func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
t.Parallel()
@@ -248,7 +213,6 @@ func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
}
}
// TestShortcutsIncludesExpectedCommands verifies the whiteboard shortcut registry includes query and update.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
@@ -273,7 +237,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
}
}
// TestParseWBcliNodes verifies whiteboard CLI output parsing for raw and wrapped node payloads.
func TestParseWBcliNodes(t *testing.T) {
t.Parallel()
@@ -322,7 +285,6 @@ func TestParseWBcliNodes(t *testing.T) {
}
}
// TestWBUpdateDryRun verifies dry-run requests for the supported whiteboard update formats.
func TestWBUpdateDryRun(t *testing.T) {
ctx := context.Background()
@@ -355,14 +317,6 @@ func TestWBUpdateDryRun(t *testing.T) {
"source": "graph TD\nA-->B",
},
},
{
name: "dry run svg format",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"input_format": "svg",
"source": "<svg/>",
},
},
}
for _, tt := range tests {
@@ -408,7 +362,6 @@ func runUpdateShortcut(t *testing.T, shortcut common.Shortcut, args []string, fa
return err
}
// TestWhiteboardUpdateExecute_RawFormat verifies raw node updates call the raw nodes endpoint.
func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -432,7 +385,6 @@ func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_PlantUMLFormat verifies PlantUML updates use the diagram import endpoint.
func TestWhiteboardUpdateExecute_PlantUMLFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -458,7 +410,6 @@ Bob -> Alice : hello
}
}
// TestWhiteboardUpdateExecute_PlantUMLInvalidResponse verifies missing node IDs are treated as invalid responses.
func TestWhiteboardUpdateExecute_PlantUMLInvalidResponse(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -480,7 +431,6 @@ Bob -> Alice : hello
assertInvalidResponse(t, err)
}
// TestWhiteboardUpdateExecute_MermaidFormat verifies Mermaid updates use the diagram import endpoint.
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -505,44 +455,6 @@ A-->B`
}
}
// TestWhiteboardUpdateExecute_SVGFormat verifies svg update requests use syntax_type=3 and send the source payload.
func TestWhiteboardUpdateExecute_SVGFormat(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
// SVG shares the /nodes/plantuml endpoint with plantuml/mermaid via syntax_type=3.
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg/nodes/plantuml",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node_id": "node1",
},
},
}
reg.Register(stub)
source := `<svg xmlns="http://www.w3.org/2000/svg"/>`
args := []string{"+update", "--whiteboard-token", "test-token-svg", "--input_format", "svg", "--source", source}
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
if got := body["syntax_type"]; got != float64(3) {
t.Fatalf("syntax_type = %#v, want 3; body=%s", got, string(stub.CapturedBody))
}
if got := body["plant_uml_code"]; got != source {
t.Fatalf("plant_uml_code = %#v, want %q; body=%s", got, source, string(stub.CapturedBody))
}
}
// TestWhiteboardUpdateExecute_RawInvalidResponse verifies malformed raw update responses are rejected.
func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
tests := []struct {
name string
@@ -582,7 +494,6 @@ func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_RawWithIdempotent verifies raw updates pass through the idempotency token.
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -607,7 +518,6 @@ func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_RawFormatWithRawNodes verifies raw-node payloads are forwarded without DSL wrapping.
func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -631,7 +541,6 @@ func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_RawAPIError verifies raw update API failures preserve typed error metadata and hints.
func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -668,7 +577,6 @@ func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
}
}
// TestWhiteboardUpdateExecute_PlantUMLAPIError verifies diagram update API failures preserve typed error metadata.
func TestWhiteboardUpdateExecute_PlantUMLAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -699,7 +607,6 @@ invalid
}
}
// TestWhiteboardUpdateExecute_WithOverwrite verifies diagram updates send overwrite=true when requested.
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
@@ -724,7 +631,6 @@ A-->B`
}
}
// TestWhiteboardUpdateExecute_RawWithOverwrite verifies raw updates send overwrite=true when requested.
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)

View File

@@ -31,17 +31,10 @@ metadata:
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 本轮 Base 不依赖 `lark-cli schema`。SKILL 只保留路由、风险和复杂 JSON/DSL简单命令由命令自身的参数、tips 和错误恢复承接。
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先转 `lark-cli drive +import --type bitable`,导入完成后再回到 Base 命令。
- 用户只给 Base 名称或关键词时,先用 `lark-cli drive +search --query <keyword> --doc-types bitable` 定位资源。
- Base 命令必须先有 `base_token` 或可解析出的 Base URL。没有 token 时:用户要新建就用 `+base-create`;用户给标题/关键词就搜 `lark-cli drive +search --query "<base title>" --doc-types bitable --only-title --as user`;仍无法定位时,反问用户具体是哪一个 Base。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 先获取 Base Token 和所需 ID
进入任何需要目标 Base 的 shortcut 前,必须先拿到可用的 `base_token`,以及当前任务需要的 `table_id` / `view_id` / `record_id` / `form_id` / `dashboard_id` / `workflow_id` 等真实 ID不要把完整 URL、wiki token、workspace token 或孤立 raw token 直接当作 `--base-token`
- 用户输入 URL 或分享链接:先运行 `lark-cli base +url-resolve --url "<url>" --as user`,用返回的 `base_token` 和相关 ID 继续后续命令。
- 用户输入 Base 标题、关键词或不确定名称:先运行 `lark-cli base +title-resolve --title "<keyword>" --as user``--title` 传入标题中的短关键词,不超过 30 个字符;过长标题先取最有区分度的短关键词;多候选时先让用户消歧,不要猜。
- 文档嵌入 Base 标签:直接读取 `<bitable>` / `<base_refer>``token` 作为 `--base-token``table-id` 作为 `--table-id``view-id` 作为 `--view-id`;孤立 raw token 不走 `+url-resolve`
- 仍无法定位且用户不是要新建 Base 时,先反问用户要操作哪一个 Base用户要新建时才用 `+base-create`
## 快速路由
| 用户目标 | 优先命令 | 何时读 reference |
@@ -120,6 +113,22 @@ metadata:
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
## Token 与链接
| 输入类型 | 含义 / 正确处理方式 |
|---|---|
| `/base/{token}` | 普通 Base 链接;提取 `/base/` 后的 token 作为 `--base-token` |
| `/wiki/{token}` | Wiki 节点链接;先 `wiki +node-get`,当 `data.obj_type=bitable` 时使用 `data.obj_token` 作为 `--base-token` |
| `/base/{token}?table={id}` | `table` 参数用于定位 Base 内对象:`tbl` 开头是数据表 `--table-id``blk` 开头是 dashboard ID`wkf` 开头是 workflow ID |
| `/base/{token}?view={id}` | `view` 参数用于定位表视图,提取为 `--view-id`;通常还需要确认 `table` 参数或先查表结构 |
| `/share/base/form/{shareToken}` | 表单分享链接;这是表单 share token`+form-detail` / `+form-submit --share-token <shareToken>` |
| `/share/base/view/{shareToken}` | 视图分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
| `/share/base/dashboard/{shareToken}` | 仪表盘分享链接;具有分享权限语义,暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开 |
| `/record/{shareToken}` | 记录分享链接;暂不支持用 CLI 直接访问,引导用户在浏览器或飞书客户端打开。若用户想生成现有记录的分享链接,用 `+record-share-link-create --base-token <base_token> --table-id <table_id> --record-ids <record_id>` |
| `/base/workspace/{token}` | BaseApp / workspace 链接;暂不支持用 CLI 直接访问 |
`wiki +node-get` 返回非 `bitable` 时,不继续使用 Base 命令:`docx` 转文档,`sheet` 转表格,其他云空间对象转对应 skill 或 drive。
## Dashboard / Workflow / Role
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,只在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`
@@ -130,7 +139,7 @@ metadata:
| 错误 / 现象 | 恢复动作 |
|---|---|
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按入口规则重新获取真实 `base_token` |
| `param baseToken is invalid` / `base_token invalid` | 检查是否把 wiki token、workspace token 或完整 URL 当成了 `--base-token`;按 `Token 与链接` 重新定位真实 Base token |
| `not found` 且输入来自 Wiki 链接 | 优先检查是否把 wiki token 当成 base token不要立刻改走裸 API |
| `1254045` 字段名不存在 | 重新 `+field-list`,使用真实字段名或字段 ID注意空格、大小写和跨表字段 |
| `1254015` 字段值类型不匹配 | 先 `+field-list`,再按 [lark-base-cell-value.md](references/lark-base-cell-value.md) 构造 CellValue |

View File

@@ -5,7 +5,7 @@ description: "飞书云文档Docx / Wiki 文档v2 API读取和编辑
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
cliHelp: "lark-cli docs --api-version v2 --help; lark-cli docs +create --api-version v2 --help; lark-cli docs +fetch --api-version v2 --help; lark-cli docs +update --api-version v2 --help; lark-cli docs resource-download --help; lark-cli docs resource-update --help; lark-cli docs resource-delete --help"
---
# docs (v2)
@@ -21,15 +21,15 @@ lark-cli docs +create --api-version v2 --content '<title>标题</title><p>内容
lark-cli docs +update --api-version v2 --doc "文档URL或token" --command append --content '<p>内容</p>'
```
## 操作入口 — 执行操作前必读
## 前置条件 — 执行操作前必读
**CRITICAL — 先根据操作大类查 [`lark-doc-operation-guide.md`](references/lark-doc-operation-guide.md),再读取该操作对应的必读 reference。**
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch --api-version v2`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
操作 guide 把“操作大类 → 必读 reference → 条件加读 → 易混边界”集中维护,避免只凭记忆选择参数或遗漏格式规则。
**所有操作通用前置:** MUST 先读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),了解认证、权限处理、全局参数、安全规则和路径限制。
**未读完 guide 中对应操作的必读文件就执行操作会导致参数选择错误或格式错误。**
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
> **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML可用 callout、grid、checkbox 等富 block
@@ -44,7 +44,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs resource-download/resource-update/resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
@@ -60,6 +60,21 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
| `<vc-transcribe-tab vc-node-id="...">` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id <vc-node-id>` |
| `<synced_reference src-token="..." src-block-id="...">` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block |
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
| [`resource-download` / `resource-update` / `resource-delete`](references/lark-doc-resource-cover.md) | Download, update, or delete a Docx cover image resource with `--type cover` |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
## 不在本 Skill 范围
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)

View File

@@ -91,7 +91,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
}
```
`content` 的格式由 `--doc-format` 决定`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
`content` 的格式由 `--doc-format` 决定。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
## 参数
@@ -99,7 +99,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|------|------|------|
| `--api-version` | 是 | 固定传 `v2` |
| `--doc` | 是 | 文档 URL 或 token支持 `/docx/``/wiki/` |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` |
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
@@ -124,7 +124,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
- `<img>` / `<source>``url` 时,直接用该 URL 下载即可(普通 HTTP GET无需走 shortcut。
- 没有 `url`、或只想预览 → `docs +media-preview --token <token> --output ./preview_media`
- 明确下载,或目标是 `<whiteboard>`(画板只能走 shortcut`docs +media-download --token <token> --output ./downloaded_media`
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs +resource-download/+resource-update/+resource-delete --type cover`
- 文档封面图不是正文素材;下载/更新/删除封面图 → `docs resource-download/resource-update/resource-delete --type cover`
## 嵌入电子表格 / 多维表格

View File

@@ -1,6 +1,6 @@
# Markdown 格式参考
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用。
## 创建文档标题

View File

@@ -1,39 +0,0 @@
# lark-doc 操作入口 Guide
本文件维护执行前的入口判断:先用“操作大类前置”确定必读 reference再用“易混边界”避免跨 skill 或资源类型选错。具体参数、示例和工作流仍以各 reference 为准。
所有操作都默认先读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),了解认证、权限、安全规则、全局参数和路径限制。
## 操作大类前置
| 操作大类 | 触发场景 | 必读 reference | 条件加读 |
|-|-|-|-|
| 读取文档 | 浏览、总结、摘取正文、定位 block、获取直达链接、提取素材或嵌入对象 token | [`lark-doc-fetch.md`](lark-doc-fetch.md) | 需要 Markdown 输出或基于 Markdown 更新时读 [`lark-doc-md.md`](lark-doc-md.md) |
| 创建文档 | 新建 Docx/Wiki 文档含短文档、长文档骨架、Markdown 导入 | [`lark-doc-create.md`](lark-doc-create.md), [`lark-doc-xml.md`](lark-doc-xml.md) | 用户提供 `.md` 或明确要求 Markdown 时读 [`lark-doc-md.md`](lark-doc-md.md);长文档读 [`style/lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md);需要根据题材组织文档时读 [`style/topics/topic-router.md`](style/topics/topic-router.md);需要富 block 或美化时读 [`style/lark-doc-style.md`](style/lark-doc-style.md) |
| 编辑文档 | 替换、插入、删除、移动、复制、追加、覆盖、改写、润色、重排版 | [`lark-doc-update.md`](lark-doc-update.md), [`lark-doc-xml.md`](lark-doc-xml.md) | 用户明确要求 Markdown 或需 Markdown 跨行匹配时读 [`lark-doc-md.md`](lark-doc-md.md);改写/润色读 [`style/lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md);需要富 block 或美化时读 [`style/lark-doc-style.md`](style/lark-doc-style.md) |
| 正文素材 | 插入、预览或下载正文图片/附件,下载画板缩略图 | 对应操作的 [`lark-doc-media-insert.md`](lark-doc-media-insert.md) / [`lark-doc-media-preview.md`](lark-doc-media-preview.md) / [`lark-doc-media-download.md`](lark-doc-media-download.md) | 需要从文档中提取素材 token 时先读 [`lark-doc-fetch.md`](lark-doc-fetch.md) |
| 文档级资源 | 下载、更新或删除 Docx 封面图 | [`lark-doc-resource-cover.md`](lark-doc-resource-cover.md) | 无;封面不是正文 `<img>`,不要走 `+media-*` |
| 画板协作 | 新增 Mermaid/SVG 画板,或更新已有画板 | [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) | 插入新的 `<whiteboard>` block 时读 [`lark-doc-xml.md`](lark-doc-xml.md);更新已有复杂画板时读 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md);需要美化/结构化表达时读 [`style/lark-doc-style.md`](style/lark-doc-style.md) |
| 嵌入对象下钻 | 正文中出现 `<sheet>``<bitable>``<cite file-type=...>``<vc-transcribe-tab>``<synced_reference>` 等 | [`lark-doc-fetch.md`](lark-doc-fetch.md) | 按对象类型切到 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)、[`../../lark-base/SKILL.md`](../../lark-base/SKILL.md)、[`../../lark-note/SKILL.md`](../../lark-note/SKILL.md) 或继续用 `docs +fetch` 读取源文档 |
| 非本 skill | 评论、评论回复、reaction、权限、云空间文件管理、导入导出 | 对应目标 skill | 评论/云空间管理走 [`../../lark-drive/SKILL.md`](../../lark-drive/SKILL.md);表格/Base 内部数据走 sheets/base |
## 易混边界
- 正文图片、附件和画板缩略图走正文素材操作;文档封面走 [`lark-doc-resource-cover.md`](lark-doc-resource-cover.md),不要把封面当正文 `<img>` 处理。
- 已有复杂画板的查询、导出、渲染验证和写入以 [`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 的流程为准。
- 评论、权限、云空间文件管理、导入导出不归本 skill按场景切到 [`../../lark-drive/SKILL.md`](../../lark-drive/SKILL.md)。
- 文档内嵌 `<sheet>` / `<bitable>` / `<cite file-type=...>` 时,本 skill 只负责提取 token对象内部数据读取和修改切到对应 skill。
## 格式选择
- **创建 / 导入场景**XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说“导入 Markdown”时直接用 Markdown否则默认 XML。
- **精准编辑场景**`str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修优先 XML。
- **Markdown 限制**Markdown 不携带 block ID也无样式。需要按 block ID 定位时,先用 `docs +fetch --detail with-ids` 局部获取目标段落。
- **富 block**callout、grid、table、whiteboard 等结构化表达由内容和用户意图决定;不要为了“丰富”强行套用固定结构。
## 校验要点
- 写操作后,如继续 block 级操作,按 [`lark-doc-update.md`](lark-doc-update.md) 的“Block ID 生命周期”判断是否需要重新 fetch。
- `overwrite` / `block_replace` / `block_delete` 后不要复用受影响旧 ID。
- 插入 / 复制新块后,要操作新块必须重新 fetch 获取新 block ID。
- 正文素材走 `+media-*`;文档封面走 `+resource-* --type cover`

View File

@@ -1,36 +1,36 @@
# docs +resource-*Docx 封面图资源)
# docs resource-*Docx 封面图资源)
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
Docx 封面图不是正文里的 `<img token="...">` 素材块。读取、更新、删除文档封面图时,使用 `docs +resource-download/+resource-update/+resource-delete --type cover`,不要使用 `+media-insert``+media-download --token <cover.token>` 让用户手动拼步骤。
Docx 封面图不是正文里的 `<img token="...">` 素材块。读取、更新、删除文档封面图时,使用 `docs resource-download/resource-update/resource-delete --type cover`,不要使用 `+media-insert``+media-download --token <cover.token>` 让用户手动拼步骤。
## 选择规则
- 用户要下载文档封面图:`docs +resource-download --type cover`
- 用户要设置/替换文档封面图:`docs +resource-update --type cover`
- 用户要删除文档封面图:`docs +resource-delete --type cover`
- 用户要下载文档封面图:`docs resource-download --type cover`
- 用户要设置/替换文档封面图:`docs resource-update --type cover`
- 用户要删除文档封面图:`docs resource-delete --type cover`
- 用户要下载正文图片、附件、画板缩略图:继续使用 [`docs +media-download`](lark-doc-media-download.md)
## 命令
```bash
# 下载封面图。CLI 会先读取 document.cover.token再下载图片内容并保存到本地。
lark-cli docs +resource-download --doc doxcnXXX --type cover --output ./cover
lark-cli docs resource-download --doc doxcnXXX --type cover --output ./cover
# 使用本地文件更新封面图。
lark-cli docs +resource-update --doc doxcnXXX --type cover --file ./cover.png
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png
# 使用剪切板图片更新封面图。
lark-cli docs +resource-update --doc doxcnXXX --type cover --from-clipboard
lark-cli docs resource-update --doc doxcnXXX --type cover --from-clipboard
# 使用 HTTPS URL 更新封面图。CLI 会先下载 URL 内容,再上传并写入 cover.token。
lark-cli docs +resource-update --doc doxcnXXX --type cover --url "https://example.com/cover.png"
lark-cli docs resource-update --doc doxcnXXX --type cover --url "https://example.com/cover.png"
# 可选:设置封面图裁切偏移。
lark-cli docs +resource-update --doc doxcnXXX --type cover --file ./cover.png --offset-ratio-x 0.2 --offset-ratio-y 0.8
lark-cli docs resource-update --doc doxcnXXX --type cover --file ./cover.png --offset-ratio-x 0.2 --offset-ratio-y 0.8
# 删除封面图;当文档本来没有封面图时也成功返回。
lark-cli docs +resource-delete --doc doxcnXXX --type cover
lark-cli docs resource-delete --doc doxcnXXX --type cover
```
## 参数
@@ -49,13 +49,13 @@ lark-cli docs +resource-delete --doc doxcnXXX --type cover
## 输出契约
- `+resource-download` 成功时 stdout JSON 的 `data` 包含 `document_id``type``saved_path``size_bytes``content_type``cover.token`。如果文档没有封面图,命令失败退出,错误包含 `document has no cover` 和脱敏 `document_id`,不会创建输出文件。
- `+resource-update` 成功时 stdout JSON 的 `data` 包含完整 `file_token``cover.token`stderr 只打印脱敏 token。
- `+resource-delete` 成功时 stdout JSON 的 `data.deleted` 表示本次是否真的发起删除,`data.already_empty` 表示删除前是否没有封面图。空封面图是幂等成功,不报错。
- `resource-download` 成功时 stdout JSON 的 `data` 包含 `document_id``type``saved_path``size_bytes``content_type``cover.token`。如果文档没有封面图,命令失败退出,错误包含 `document has no cover` 和脱敏 `document_id`,不会创建输出文件。
- `resource-update` 成功时 stdout JSON 的 `data` 包含完整 `file_token``cover.token`stderr 只打印脱敏 token。
- `resource-delete` 成功时 stdout JSON 的 `data.deleted` 表示本次是否真的发起删除,`data.already_empty` 表示删除前是否没有封面图。空封面图是幂等成功,不报错。
## URL 来源安全边界
`+resource-update --url` 只用于下载公开 HTTPS 图片:
`resource-update --url` 只用于下载公开 HTTPS 图片:
- 只允许 `https://`,拒绝 HTTP、空 host 和 URL userinfo。
- 拒绝解析到 private、loopback、link-local、multicast、unspecified 地址的 host。

View File

@@ -19,31 +19,30 @@
### 步骤一:规划与初始创建(串行)
1. 分析用户需求:受众、目的、范围
2. 如需从零组织文档且用户未指定固定模板,先读 [`topics/topic-router.md`](topics/topic-router.md) 判断题材;命中题材后加读对应题材指引,再设计大纲
3. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比
4. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
2. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比
3. `docs +create --api-version v2` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
### 步骤二:分段撰写(并行 Agent
5. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
- `lark-doc-xml.md` 的完整路径Agent 须先读取);仅在需要使用富 block 或用户要求美化时提供 `lark-doc-style.md`
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
### 步骤三:整合审查与画板识别(串行)
6. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
7. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
8. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
5. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
### 步骤四:画板处理与润色(并行 Agent
9. **优先处理步骤三识别出的画板需求**
8. **优先处理步骤三识别出的画板需求**
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
10. Spawn 内容改写 Agent 定向润色:
9. Spawn 内容改写 Agent 定向润色:
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
- 本地图片使用 `docs +media-insert` 插入

View File

@@ -1,20 +0,0 @@
# 会议纪要文档指引
## 适用场景
会议纪要、同步会记录、讨论结论、评审记录等需要沉淀共识和行动项的文档。
## 结构建议
- 会议信息:主题、时间、参会人
- 背景或议题
- 讨论要点
- 已达成结论
- 待办事项与负责人
- 未决问题
## 表达建议
- 先写结论和行动项,再保留必要讨论过程。
- 待办事项尽量写清负责人、截止时间和验收标准。
- 已知 open_id 的人员使用 `<cite type="user" user-id="..."></cite>`

View File

@@ -1,20 +0,0 @@
# 项目计划文档指引
## 适用场景
项目计划、推进方案、排期、里程碑、跨团队协作方案等需要说明目标、范围、路径和风险的文档。
## 结构建议
- 背景与目标
- 范围与不做事项
- 里程碑与时间安排
- 工作拆解与责任分工
- 风险、依赖与应对
- 下一步动作
## 表达建议
- 时间线、依赖关系和关键路径适合用表格或画板。
- 不要虚构日期、负责人或指标;缺失信息标为待确认。
- 风险和依赖要写出影响与应对,不只列名称。

View File

@@ -1,19 +0,0 @@
# 汇报总结文档指引
## 适用场景
周报、月报、阶段总结、复盘、述职、项目汇报等需要向读者说明进展、结果、问题和下一步的文档。
## 结构建议
- 结论或总体状态
- 关键进展与成果
- 数据、事实或案例支撑
- 问题、风险与原因
- 下一步计划
## 表达建议
- 面向管理层时先给结论,再展开细节。
- 有指标变化时说明口径,不要只堆数字。
- 风险、阻塞和待决策事项可以用列表或 callout但不要过度使用。

View File

@@ -1,16 +0,0 @@
# 文档题材路由
创建文档前,如果用户没有给定固定模板,先根据用户意图识别题材,再加读对应题材指引。题材指引用于帮助选择结构、语气和信息组织方式,不是强制模板。
| 用户意图 / 信号 | 题材 | 加读指引 |
|-|-|-|
| 周报、月报、复盘、总结、汇报、述职 | 汇报总结 | [`report.md`](report.md) |
| 会议纪要、会议总结、讨论记录、同步会 | 会议纪要 | [`meeting-notes.md`](meeting-notes.md) |
| 项目计划、排期、里程碑、推进方案 | 项目计划 | [`project-plan.md`](project-plan.md) |
## 规则
- 用户明确指定题材、结构或模板时,优先用户指定。
- 命中多个题材时,只选择主目标对应的 1 个题材,不组合多个指引。
- 题材指引只提供结构和表达建议,不覆盖用户给出的事实、格式和语气要求。
- 没有明显题材时,不强行套题材,按通用创建工作流执行。

View File

@@ -1,7 +1,7 @@
---
name: lark-event
version: 1.0.0
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
metadata:
requires:
bins: ["lark-cli"]
@@ -147,8 +147,7 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
| Topic | Reference | Coverage |
|------------|------------------------------------------------------------------------------|---|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 11 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |

View File

@@ -4,7 +4,7 @@
>
> **Heads-up for AI agents**: this key's `.content` is **NOT** the raw OAPI payload shape your training data may suggest. `lark-cli` runs a Process hook (`convertlib`) that flattens the V2 envelope and **pre-renders** `.content` to human-readable text for `text` / `post` / `image` / `file` / `audio` / etc. Only `interactive` (cards) keeps the raw JSON string. Don't blindly `fromjson`.
## Key catalog (12)
## Key catalog (11)
| EventKey | Purpose |
|---|---|
@@ -19,9 +19,8 @@
| `im.chat.member.user.added_v1` | User joined a chat (including topic chats) |
| `im.chat.member.user.deleted_v1` | User left voluntarily **or** was removed |
| `im.chat.member.user.withdrawn_v1` | Pending chat invite withdrawn (inviter canceled; user never actually joined) |
| `card.action.trigger` | Interactive card callback — button click, form submit, dropdown, etc. → see [`lark-im-card-action-reply.md`](../../lark-im/references/lark-im-card-action-reply.md) |
> **Shape**: All 12 events have a V2-enveloped raw payload. `lark-cli` flattens two of them — `im.message.receive_v1` and `card.action.trigger` — so their consumed output is flat (fields at `.xxx`). The other 10 are passed through as-is; use `.event.xxx` to access their fields.
> **Shape**: `im.message.receive_v1` is the only flat key (fields at `.xxx`); the other 10 are V2-enveloped (fields at `.event.xxx`).
## Gotchas (`im.message.receive_v1`)

View File

@@ -1,78 +0,0 @@
# Task Events
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (1)
| EventKey | Purpose |
|---|---|
| `task.task.update_user_access_v2` | A visible task has been created, deleted, or updated |
This key uses a **Native schema** (V2 envelope; output rooted at `.event`) and carries a **PreConsume hook** that calls the Task event subscription API before listening.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `task.task.update_user_access_v2` | `task:task:read` | user, bot |
Supports `--as user` or `--as bot`.
- `--as user`: receive task updates visible to the current user through authorship, assignment, following, or other access.
- `--as bot`: receive task updates for tasks the application is responsible for.
## `task.task.update_user_access_v2`
### Subscription behavior
On startup, `event consume` calls:
```text
POST /open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id
```
The Task subscription API has no matching unsubscribe endpoint in the current CLI metadata, so graceful exit has no cleanup call for this EventKey. Re-running the consumer repeats the subscribe call for the selected identity.
This EventKey is single-consumer per local bus subscription: start one `event consume task.task.update_user_access_v2` process for a given app/profile/identity at a time.
### Output fields (V2 envelope; root path `.event`)
| Field | Type | Description |
|---|---|---|
| `.header.event_id` | string | Globally unique event ID; safe for deduplication |
| `.header.create_time` | string (timestamp_ms) | Event creation time in milliseconds |
| `.event.event_types[]` | string enum | Task commit types included in this event |
| `.event.task_guid` | string (kind=task_guid) | Task GUID that changed |
Commit types:
```text
task_assignees_update
task_completed_update
task_create
task_deleted
task_desc_update
task_followers_update
task_reminders_update
task_start_due_update
task_summary_update
```
### Example
```bash
# Stream task update events for the current user
lark-cli event consume task.task.update_user_access_v2 --as user
# Sample one event for payload inspection
lark-cli event consume task.task.update_user_access_v2 \
--as user --max-events 1 --timeout 2m
# Project to a compact task-update record
lark-cli event consume task.task.update_user_access_v2 \
--as user \
--jq '{event_id: .header.event_id, task_guid: .event.task_guid, event_types: .event.event_types, timestamp: .header.create_time}'
# Consume as the app identity
lark-cli event consume task.task.update_user_access_v2 --as bot
```

View File

@@ -1,7 +1,7 @@
---
name: lark-im
version: 1.0.0
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急、发送和处理交互卡片Interactive Card、监听卡片按钮回调card.action.trigger。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据、处理卡片回调时使用。"
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。"
metadata:
requires:
bins: ["lark-cli"]
@@ -61,16 +61,6 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m
Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr.
`interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md).
### Audio Messages
`--audio` sends a voice message and supports only Opus audio files, for example `.opus` files or Ogg Opus (`.ogg`) files. For `mp3`, `wav`, or other non-Opus audio, either convert to `.opus` first and keep using `--audio`, or send the original file as an attachment with `--file`.
### Sending Doc Content as a Message
When sending content fetched from a Lark doc as a message, fetch the doc with --doc-format im-markdown, then send it as a message using the --markdown format. The fetched content is already in markdown; in any content-forwarding scenario, keep the fetched original text and send it in the --markdown format. Note: if the doc contains a cite tag with type="user", keep it as-is and do not strip the tag.
### Flag Types
Flags support two layers:

View File

@@ -1,175 +0,0 @@
# card.action.trigger
> **Prerequisite:** Read [`../../lark-event/SKILL.md`](../../lark-event/SKILL.md) first for `event consume` essentials.
Fires when a user interacts with an interactive card — button click, form submit, dropdown select,
checkbox toggle, date/time pick, etc.
## Setup (required)
> **Console configuration required**: In the Feishu Developer Console, go to
> **App → Events & Callbacks → Callback Configuration** (应用--事件与回调--回调配置) and enable it.
> The consumer starts without errors even when not configured, but **no events will be received**.
> There is no preflight check for this setting.
After enabling, events are delivered over the existing WebSocket long connection — no additional
URL configuration needed.
## Scopes & auth
| Scope | Required for |
|---|---|
| `im:message:readonly` | Auto-fetch `card_content` via message get API (covers both p2p and group messages) |
Auth: `bot` only.
## Output fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Always `card.action.trigger` |
| `event_id` | string | Unique event ID; safe for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time (ms since epoch) |
| `operator_id` | string (open_id) | Open ID of the user who interacted |
| `message_id` | string (message_id) | Message ID of the card (`om_xxx`) |
| `chat_id` | string (chat_id) | Chat ID (`oc_xxx`) |
| `host` | string | `im_message` (chat card) or `im_top_notice` (top banner) |
| `token` | string | Delayed-update token; valid 30 min, max 2 uses |
| `action_tag` | string | Component type that was triggered (see decision table) |
| `action_value` | string | Developer-defined value on the component; serialized to JSON string |
| `action_name` | string | `name` attribute of the component |
| `timezone` | string | User timezone, e.g. `Asia/Shanghai`; only populated for date/time picker interactions |
| `form_value` | string (JSON) | All form field values as JSON string, keyed by component `name`; only present when a button inside a form container is clicked |
| `input_value` | string | Input text; only for standalone `input` components (not inside a form) |
| `option` | string | Selected value for standalone single-select: `select_static`, `select_person`, `overflow`, `date_picker`, `picker_time`, `picker_datetime` |
| `options` | string | Comma-separated selected values for standalone multi-select: `multi_select_static`, `multi_select_person` |
| `checked` | bool | Checkbox state for standalone `checker` elements |
| `card_content` | string | Original card content (userDSL text format) from when the card was sent; auto-fetched via message get API at consume time; empty if `message_id` absent or fetch fails — skip if empty |
## `card_content` — what it is and how to use it
`card_content` is the `user_dsl` field extracted from the card message content, auto-fetched
at event consume time. It represents the card's original definition — use it as the starting
point to understand the current card structure and construct the updated card JSON.
No extra API call is needed — the consumer fetches it automatically. If empty, skip — no fallback required.
## action_tag decision table
> **Form container rule**: when a component is inside a `form` container, its value appears in
> `form_value[name]` instead of the standalone fields (`option`, `options`, `input_value`,
> `checked`). There is no `form_submit` tag — form submission comes through as `button` with
> `form_value` populated.
| `action_tag` | Read field(s) | Notes |
|---|---|---|
| `button` | `action_value` (fromjson if object); `form_value` if inside a form | Most common; `form_value` non-empty = form submit |
| `overflow` | `option` | Collapsible button group selection |
| `select_static` | `option` (standalone) or `form_value[name]` (in form) | Single-select dropdown |
| `multi_select_static` | `options` (standalone) or `form_value[name]` (in form) | Multi-select dropdown |
| `select_person` | `option` — open_id of selected user | Single-select person |
| `multi_select_person` | `options` — comma-separated open_ids | Multi-select person |
| `input` | `input_value` (standalone) or `form_value[name]` (in form) | Text input |
| `checker` | `checked` (standalone) or `form_value[name]` (in form) | Checkbox |
| `date_picker` | `option` (date string) + `timezone` | e.g. `"2024-04-01 +0800"` |
| `picker_time` | `option` (time string) + `timezone` | e.g. `"08:30 +0800"` |
| `picker_datetime` | `option` (datetime string) + `timezone` | e.g. `"2024-04-29 07:07 +0800"` |
| `select_img` | `option` (single) or `options` (multi) | Image picker |
## Key constraints
1. Token **valid 30 minutes**, **max 2 uses** — if update fails after exhaustion, inform the user
2. Delayed-update API requires **complete new card JSON** — partial updates are not supported
3. SDK auto-responds `{"code":200}` within 3 s — your update call can be sent any time within 30 min
4. `card_content` is auto-populated — no extra API call needed; if empty, skip it
## After starting the listener
Once the listener is running, check whether your agent runtime supports background event
monitoring (i.e. can receive and process stdout lines from a running subprocess while
continuing to respond to the user). If it does, prompt the user:
> "Card callback listener is now active. Do you want me to automatically handle card
> interactions and update the card based on user actions?"
Only enter the auto-update workflow below if the user confirms. If your runtime does not
support background monitoring, inform the user that automatic card updates are not available
and they will need to handle interactions manually.
## Agent workflow
When a `card.action.trigger` event arrives (**each stdout JSON line is one event — process it immediately**):
```
1. Read action fields to understand what the user did:
- action_tag: which component was triggered
- action_value / option / options / checked / input_value / form_value: what value was set
2. Decide: does this interaction require a card update?
- e.g. button click with a business action → yes
- e.g. navigation / pagination → no (just record, no update needed)
- Not every callback requires a card update — decide based on business semantics
- Before updating, explicitly state what visual change the action requires. If you cannot articulate one, skip the update.
3. If update is needed:
a. If card_content is empty: inform the user that the original card could not be fetched,
so it is not possible to determine whether an update is needed — do not guess
b. Determine the new card state based on the action
c. Use card_content as the structural basis to construct the updated card JSON
d. Detect card version: if card_content contains `"schema":"2.0"` or `"schema": "2.0"` it is Card 2.0; otherwise assume Card 1.0
e. For Card 1.0: include `"open_ids": ["<operator_id>"]` inside the `card` object, or the API returns code 300090
f. Call the delayed update API with the token and new card JSON
4. If no update: end (the SDK has already acknowledged the callback)
```
## Updating the card
```bash
lark-cli api POST /open-apis/interactive/v1/card/update --as bot \
--data '{"token":"<token>","card":<new_card_json>}'
```
`--data` parameters:
| Field | Required | Description |
|---|---|---|
| `token` | Yes | Delayed-update token from the event |
| `card` | Yes | Complete new card JSON — construct based on `card_content` from the event, modified to reflect the new state |
| `card.open_ids` | No | **Card 1.0 only.** Array of `open_id`s defining which users see the updated card. Must contain at least one open_id (e.g. the operator's); passing `[]` or omitting the key both cause "openid empty" (code 300090). |
## Examples
```bash
# Stream all card interactions
lark-cli event consume card.action.trigger --as bot
# Grab one callback to inspect shape (debugging only — do not use in production workflows)
lark-cli event consume card.action.trigger --as bot --max-events 1 --timeout 60s
# Button clicks only (not form submit), with action value
lark-cli event consume card.action.trigger --as bot \
--jq 'select(.action_tag == "button" and .form_value == "") | {op: .operator_id, val: (.action_value | fromjson?), token: .token}'
# Form submits (button with form_value present)
lark-cli event consume card.action.trigger --as bot \
--jq 'select(.action_tag == "button" and .form_value != "") | {op: .operator_id, form: (.form_value | fromjson), token: .token}'
# Date picker interactions
lark-cli event consume card.action.trigger --as bot \
--jq 'select(.action_tag == "date_picker") | {op: .operator_id, date: .option, tz: .timezone}'
# Filter to one chat
lark-cli event consume card.action.trigger --as bot \
--jq 'select(.chat_id == "oc_xxx")'
```
## Gotchas
- **No `form_submit` tag**: form submission comes as `action_tag = "button"` with `form_value`
populated. Check `form_value != ""` to distinguish from a standalone button click.
- **`action_value` type is developer-defined**: the original may be an object or a plain string.
Use `fromjson?` (with `?` to swallow errors) or check before parsing.
- **Standalone vs form fields**: `input_value`, `option`, `options`, `checked` are only populated
for components **not** inside a form container. Inside a form, all values appear in `form_value`.
- **WebSocket delivery**: no separate callback URL needed; uses the existing WS connection.

View File

@@ -147,9 +147,6 @@ lark-cli im +messages-reply --message-id om_xxx --file ./report.pdf
# Reply with a local video (--video-cover is required as the video cover)
lark-cli im +messages-reply --message-id om_xxx --video ./demo.mp4 --video-cover ./cover.png
# Reply with a voice message
lark-cli im +messages-reply --message-id om_xxx --audio ./voice.opus
# With an idempotency key
lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency-key my-unique-id
@@ -162,7 +159,6 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
- `--audio` sends a voice message and accepts only Opus audio (`.opus` or Ogg Opus `.ogg`) for local paths and URLs. For `mp3`, `wav`, or other non-Opus audio, convert to `.opus` before using `--audio`, or use `--file` to send the original audio as an attachment.
## Parameters
@@ -177,7 +173,7 @@ lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' -
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`) |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`); **must be used together with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`) |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`) |
| `--reply-in-thread` | No | Reply inside the thread. The reply appears in the target message's thread instead of the main chat stream |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one reply within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |

View File

@@ -150,7 +150,7 @@ lark-cli im +messages-send --chat-id oc_xxx --file ./report.pdf
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover ./cover.png
lark-cli im +messages-send --chat-id oc_xxx --video ./demo.mp4 --video-cover img_xxx
# Send a voice message
# Send audio
lark-cli im +messages-send --chat-id oc_xxx --audio ./voice.opus
# Use an idempotency key (same key sends only once within 1 hour)
@@ -165,7 +165,6 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
- Media flags accept an existing key (`img_xxx` / `file_xxx`), an `http://` or `https://` URL, or a local file path.
- Local paths must be relative to the current working directory and stay within it after resolving `..` and symlinks.
- Absolute paths such as `/tmp/photo.png` are rejected. Run the command from the file's directory and pass `./photo.png`, or copy the file into the current directory first.
- `--audio` sends a voice message and accepts only Opus audio (`.opus` or Ogg Opus `.ogg`) for local paths and URLs. For `mp3`, `wav`, or other non-Opus audio, convert to `.opus` before using `--audio`, or use `--file` to send the original audio as an attachment.
## Parameters
@@ -180,7 +179,7 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
| `--file <path\|url\|key>` | One content option | Cwd-relative local file path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--video <path\|url\|key>` | One content option | Cwd-relative local video path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically. **Must be paired with `--video-cover`** |
| `--video-cover <path\|url\|key>` | **Required with `--video`** | Cwd-relative local cover image path, URL, or `image_key` (`img_xxx`). Local paths and URLs are uploaded automatically |
| `--audio <path\|url\|key>` | One content option | Voice-message audio key, URL, or cwd-relative local path. Local paths and URLs must be Opus (`.opus` or Ogg Opus `.ogg`) |
| `--audio <path\|url\|key>` | One content option | Cwd-relative local audio path, URL, or `file_key` (`file_xxx`). Local paths and URLs are uploaded automatically |
| `--msg-type <type>` | No | Message type (default `text`). If you use `--text` / `--markdown` / media flags, the effective type is inferred automatically. Explicitly setting a conflicting `--msg-type` fails validation |
| `--idempotency-key <key>` | No | Idempotency key; the same key sends only one message within 1 hour |
| `--as <identity>` | No | Identity type: `bot` or `user` (default `bot`) |
@@ -215,8 +214,6 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry
| `share_user` | `{"user_id":"ou_xxx"}` |
| `interactive` | Card JSON (see Feishu interactive card documentation) |
`interactive` cards support callback events (`card.action.trigger`) — see [`lark-im-card-action-reply.md`](lark-im-card-action-reply.md).
## Return Value
```json

View File

@@ -132,6 +132,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range会跨过表中部的整行空行 / 整列空列,不会被截断) |
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
| `--dataframe-out` | string | optional | 以一份 Arrow IPC 文件Feather v2格式输出 typed 表格,替代默认的 JSON 输出。用 `@<path>` 传文件或 `-` 写二进制 stdout同其他 binary I/O flag 的约定)。是 `+table-put` / `+workbook-create` 入口 `--dataframe` 的镜像 —— pandas 端 `pd.read_feather("x.arrow")``pd.read_feather(io.BytesIO(stdout))` 一行读回。仅支持单 sheet必须给 `--sheet-id``--sheet-name`;读整本 workbook 仍走默认 JSON。列类型沿用 typed 读回string/number/date/bool`number_format` 以 Arrow Field metadata 保留Arrow 文件可直接喂回 `+table-put --dataframe`。 |
## Examples
@@ -203,6 +204,34 @@ df_sales = sheets["销售"]
> 显示格式(千分位、百分比、自定义日期)在 `sheet["formats"]`pandas 不消费;改完数据 round-trip 回去时透传给 `+table-put` 即可,飞书侧显示不变。
#### `--dataframe-out`Arrow IPC / Feather v2 二进制读出)
`--dataframe-out``+table-put` 入口 `--dataframe` 的镜像:把 typed 读回直接编码成 Arrow IPC 文件pandas 端一行 `pd.read_feather()` 读回——省掉 JSON 解析 + `astype(dtypes)`,列类型 / `number_format` 走 Arrow schema + Field metadata 保真。**仅支持单 sheet**Arrow 文件一 schema 容器),必须给 `--sheet-id``--sheet-name`;读整本 workbook 仍走默认 JSON。
```bash
# 文件
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" --dataframe-out @./out.arrow
# binary stdout不落盘
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" --dataframe-out -
```
```python
import io, pandas as pd, subprocess
# 1) 文件
subprocess.run(["lark-cli","sheets","+table-get","--url",URL,
"--sheet-name","销售","--dataframe-out","@./out.arrow"], check=True)
df = pd.read_feather("./out.arrow")
# 2) stdin/stdout 管道(不落盘)—— 跟 --dataframe 写入侧对称的一行
res = subprocess.run(["lark-cli","sheets","+table-get","--url",URL,
"--sheet-name","销售","--dataframe-out","-"],
capture_output=True, check=True)
df = pd.read_feather(io.BytesIO(res.stdout))
```
> `number_format` 进 Arrow Field metadatakey=`number_format`Arrow 文件可以直接喂回 `+table-put --dataframe` round-trip 写回types / formats 一路保真。
#### round-trip读 → 改 → 写回(写读对偶)
`sheet_to_df``df_to_sheet` 一对镜像 helper[`scripts/sheets_df.py`](../scripts/sheets_df.py))让 round-trip 三段读 / 改 / 写各一行:

View File

@@ -139,8 +139,9 @@ _系统`--dry-run`_
| `--title` | string | required | 新 spreadsheet 标题 |
| `--folder-token` | string | optional | 目标文件夹 token省略时放在云空间根目录 |
| `--values` | string + File + Stdin简单 JSON | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
| `--sheets` | string + File + Stdin复合 JSON | optional | 建表后写入的 typed 表格协议 JSON同 +table-put顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/sheets_df.py``df_to_sheet(df, name)` 把 DataFrame 转成一项再包 `{"sheets":[...]}`。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--sheets` | string + File + Stdin复合 JSON | optional | 建表后写入的 typed 表格协议 JSON同 +table-put顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常`{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。与 --values、--dataframe 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--styles` | string + File + Stdin复合 JSON | optional | 建表时同时写入的视觉处理操作 JSON顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style含 number_format / 颜色 / 对齐 / border_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles`。 |
| `--dataframe` | string | optional | 单 sheet 类型保真表格的二进制入口,从一个 Arrow IPC 文件Feather v2pandas `df.to_feather()` 直接写出)读入,与 --values / --sheets 互斥。用 `@<path>` 传文件或 `-` 读二进制 stdin同其他输入 flag 的约定。Arrow 字节按原样读 —— 不做 TrimSpace / BOM stripIPC magic 字节完整保留(区别于文本类输入 flag。列类型从 Arrow schema 推导;每列的 `number_format` 可写在 Arrow Field metadata 里。建表后写入默认子表(`Sheet1` —— 直接复用,不残留空 Sheet1。要多子表或换落点请改用 `--sheets`。 |
### `+workbook-export`
@@ -199,7 +200,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
### `+workbook-create`
新建电子表格,可选预填数据。种数据入口untyped `--values` / typed `--sheets` JSON**互斥**,按需选一——者都走同一条分批写入:
新建电子表格,可选预填数据。种数据入口untyped `--values` / typed `--sheets` JSON / typed `--dataframe` Arrow 二进制**三方互斥**,按需选一——者都走同一条分批写入:
```bash
# 1) untyped--values一个二维数组表头并入第一行值原样写、类型由飞书自动识别
@@ -217,9 +218,16 @@ lark-cli sheets +workbook-create --title "交易" --sheets '{
"formats":{"金额":"#,##0.00"},
"data":[["2024-01-15",1234.5,"00123"]]}
]}'
# 3) typed binary--dataframepandas df.to_feather 直接出Arrow IPC / Feather v2
# 单子表(落点固定为新表的默认子表,原地复用、不残留空 Sheet1列类型从 Arrow
# schema 自动恢复,无需手填 dtypes/formats要多子表回到 --sheets。
lark-cli sheets +workbook-create --title "交易" --dataframe @./in.arrow
# 或走 stdin不落盘
python prepare.py | lark-cli sheets +workbook-create --title "交易" --dataframe -
```
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`)。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file``--dataframe` 是同一份 typed 数据的二进制 wireArrow IPC详见同 reference 的 `+table-put` 段落的 `--dataframe` 小节),按 producer 已有的 API 选——pandas 走 `--dataframe`,多子表 / 手拼 JSON 走 `--sheets`。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trippandas 用户也可走 `--dataframe-out` 直拿 Arrow 文件)。
> 💡 pandas DataFrame 走 `--sheets` 时直接 `from sheets_df import df_to_sheet`[`scripts/sheets_df.py`](../scripts/sheets_df.py),与 `+table-put` 共用同一份 helper多子表场景 helper 优势更明显:
> ```python

View File

@@ -315,8 +315,9 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheets` | string + File + Stdin复合 JSON | required | Typed 表格协议pandas-DataFrame-shapedJSON:顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/sheets_df.py``df_to_sheet(df, name)` 一行把 DataFrame 转成一项(多子表就 list 拼起来再包 `{"sheets":[...]}``dtypes` 值是 pandas dtype 字符串(`int64``float64``Int64``bool``boolean``datetime64[ns]``object`、...CLI 端映射成内部 string/number/date/bool —— 省略 `dtypes` 时该列按文本写入(适合原始 CSV-shaped 数据)。`formats[col]` 是 Excel number_format 字符串(如 `#,##0.00``0.0%``yyyy-mm`);缺省时 date 列用 `yyyy-mm-dd`string 列用文本格式 `@`。 |
| `--styles` | string + File + Stdin复合 JSON | optional | 类型保真写入后再应用的视觉处理操作 JSON顶层 `{styles:[...]}`,每项对应一个被写入的子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style含 number_format / 颜色 / 对齐 / border_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。styles 数组的长度/顺序/name 必须与被写入的子表对应(与 --sheets.sheets 一一对应)。完整 cell_styles 字段结构跑 `+table-put --print-schema --flag-name styles`。 |
| `--sheets` | string + File + Stdin复合 JSON | xor | Typed 表格协议pandas-DataFrame-shapedJSON,与 `--dataframe` 互斥:顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常`{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造`dtypes` 值是 pandas dtype 字符串(`int64``float64``Int64``bool``boolean``datetime64[ns]``object`、...CLI 端映射成内部 string/number/date/bool —— 省略 `dtypes` 时该列按文本写入(适合原始 CSV-shaped 数据)。`formats[col]` 是 Excel number_format 字符串(如 `#,##0.00``0.0%``yyyy-mm`);缺省时 date 列用 `yyyy-mm-dd`string 列用文本格式 `@`。 |
| `--dataframe` | string | xor | 单 sheet 类型保真表格的二进制入口,从一个 Arrow IPC 文件(即 Feather v2pandas `df.to_feather()` 直接写出)读入,与 `--sheets` 互斥。用 `@<path>` 传文件或 `-` 读二进制 stdin同其他输入 flag 的约定。Arrow 字节按原样读 —— 不做 TrimSpace / BOM stripIPC magic 字节完整保留(区别于文本类输入 flag。列类型从 Arrow schema 推导int*/uint*/float* → numberdate32/date64/timestamp → dateutf8/large_utf8 → stringbool → bool每列的 `number_format` 可写在 Arrow Field metadata 里(`pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})`)。子表走默认落点:名为 `Sheet1`(缺则新建),从 A1 起覆盖写并带表头。要换子表名 / 起始位置 / 写入方式,或要写多子表,请改用 `--sheets`。 |
| `--styles` | string + File + Stdin复合 JSON | optional | 类型保真写入后再应用的视觉处理操作 JSON顶层 `{styles:[...]}`,每项对应一个被写入的子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style含 number_format / 颜色 / 对齐 / border_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。styles 数组的长度/顺序/name 必须与被写入的子表对应:配 --sheets 时与 --sheets.sheets 对应;配 --dataframe单子表名为 Sheet1时只给一个 name 为 `Sheet1` 的 styles 项。完整 cell_styles 字段结构跑 `+table-put --print-schema --flag-name styles`。 |
## Schemas
@@ -543,9 +544,44 @@ payload = {"sheets": [{
> **dtype 速查**`int64`/`float64`(数值)、`Int64`含空值的整数nullable、`bool`/`boolean`、`datetime64[ns]`date默认 `yyyy-mm-dd`)、`object`string。pandas dtype 字符串原样塞进 dtypes 即可CLI 端按前缀匹配(`int*`/`uint*`/`Int*`/`float*` → number 等)。未识别 dtype 兜底为 string。
#### `--dataframe`Arrow IPC / Feather v2 二进制入口)
`--dataframe``--sheets` 互斥、功能等价,但走二进制 wire——pandas `df.to_feather()` 写出的 Arrow IPC 文件直接喂 CLI类型从 Arrow schema 自动恢复,**不用再手填 dtypes/formats**,也自动绕过 NaT / NaN / `datetime64[ns, tz]` 的 JSON 序列化坑。子表落点固定为 `Sheet1`、A1 起覆盖写、带表头;要换子表名 / 起始位置 / 多子表,回到 `--sheets` JSON 协议。
```bash
# 文件cwd 相对路径;受 SafePath 沙箱约束,不接受绝对路径)
lark-cli sheets +table-put --url "<表URL>" --dataframe @./in.arrow
# stdin 二进制(不落盘)
python prepare.py | lark-cli sheets +table-put --url "<表URL>" --dataframe -
```
```python
import io, subprocess, pandas as pd
df = pd.DataFrame({"date": pd.to_datetime(["2024-01-15"]), "amount": [1234.5], "id": ["00123"]})
# 1) 文件
df.to_feather("./in.arrow") # 写到当前目录
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--dataframe","@./in.arrow"], check=True)
# 2) stdin不落盘—— pandas 写 BytesIOsubprocess 把 buf 灌进去
buf = io.BytesIO(); df.to_feather(buf)
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--dataframe","-"],
input=buf.getvalue(), check=True)
```
> 每列的 `number_format` 写在 Arrow Field metadata 里CLI 端自动透传到飞书显示格式(千分位 / 百分比 / 自定义日期等):
> ```python
> import pyarrow as pa, pyarrow.feather as feather
> table = pa.Table.from_pandas(df)
> schema = table.schema.set(
> table.schema.get_field_index("amount"),
> pa.field("amount", pa.float64(), metadata={b"number_format": b"#,##0.00"}))
> feather.write_feather(table.cast(schema), "./in.arrow")
> ```
#### `--styles`(写入时同时套样式)
`--styles` 在 typed 写入后顺带应用视觉处理,省掉一次 `+cells-set-style` 往返。协议与 `+workbook-create --styles` **完全同构**(详见 workbook reference顶层 `{styles:[...]}`,数组每项对应一个被写入的子表、含 `name`,并按能力拆成四类可选数组——`cell_styles`A1 单元格 range + 扁平样式字段,含 `number_format` / 颜色 / 对齐 / `border_styles`,随内容在同一次写入里一并应用)、`cell_merges``row_sizes``col_sizes`。styles 数组的长度 / 顺序 / name 必须与被写入的子表对应`--sheets.sheets` 一一对应)
`--styles` 在 typed 写入后顺带应用视觉处理,省掉一次 `+cells-set-style` 往返。协议与 `+workbook-create --styles` **完全同构**(详见 workbook reference顶层 `{styles:[...]}`,数组每项对应一个被写入的子表、含 `name`,并按能力拆成四类可选数组——`cell_styles`A1 单元格 range + 扁平样式字段,含 `number_format` / 颜色 / 对齐 / `border_styles`,随内容在同一次写入里一并应用)、`cell_merges``row_sizes``col_sizes`。styles 数组的长度 / 顺序 / name 必须与被写入的子表对应:配 `--sheets``--sheets.sheets` 对齐;配 `--dataframe`(单子表,名为 `Sheet1`)时只给一个 name 为 `Sheet1` 的 styles 项
```bash
lark-cli sheets +table-put --url "<表URL>" \
@@ -560,6 +596,6 @@ lark-cli sheets +table-put --url "<表URL>" \
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+cells-set``--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles``+cells-set-image``--range` 必须是单 cell起止 cell 相同);`+csv-put``--csv` 必须能按 RFC 4180 解析;`+table-put` 给了 `--styles` 则按子表名 / 顺序 / 数量与 `--sheets.sheets` 对齐校验;防爆参数上限校验。
- `Validate`XOR 公共四件套;`+cells-set``--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles``+cells-set-image``--range` 必须是单 cell起止 cell 相同);`+csv-put``--csv` 必须能按 RFC 4180 解析;`+table-put` 给了 `--styles` 则按子表名 / 顺序 / 数量与 `--sheets`(或 `--dataframe` 的单子表 `Sheet1`对齐校验;防爆参数上限校验。
- `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。
- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <写入区域> --include value,formula` 抽样核对。

View File

@@ -51,6 +51,7 @@ metadata:
| [`+get-my-tasks`](references/lark-task-get-my-tasks.md) | List tasks assigned to me |
| [`+get-related-tasks`](references/lark-task-get-related-tasks.md) | list tasks related to me |
| [`+search`](references/lark-task-search.md) | search tasks |
| [`+subscribe-event`](references/lark-task-subscribe-event.md) | subscribe to task events |
| [`+upload-attachment`](references/lark-task-upload-attachment.md) | upload a local file as an attachment to a task |
| [`+tasklist-create`](references/lark-task-tasklist-create.md) | create a tasklist and optionally add tasks |
| [`+tasklist-search`](references/lark-task-tasklist-search.md) | search tasklists |

View File

@@ -0,0 +1,86 @@
# task +subscribe-event
> **Prerequisites:** Please read `../lark-shared/SKILL.md` to understand authentication, global parameters, and security rules.
>
> **⚠️ Note:** This API supports both `user` and `bot` identities. Use `user` to subscribe the current user's accessible tasks; use `bot` to subscribe tasks the **application is responsible for**.
Subscribe task update events with the current identity.
This shortcut is different from `event +subscribe`:
- `task +subscribe-event` registers task-event access for the **current identity**
- with `--as user`, it subscribes the **current user** to task events for tasks they created, are responsible for, or follow
- with `--as bot`, it subscribes using the **application identity** for tasks the application is responsible for
The task event type is:
```text
task.task.update_user_access_v2
```
Within this event, task changes are represented by commit types (string values). Deduped list:
```text
task_assignees_update
task_completed_update
task_create
task_deleted
task_desc_update
task_followers_update
task_reminders_update
task_start_due_update
task_summary_update
```
Event payload shape (example):
```json
{
"event_id": "evt_xxx",
"event_types": ["task_summary_update"],
"task_guid": "task_guid_xxx",
"timestamp": "1775793266152",
"type": "task.task.update_user_access_v2"
}
```
- `type`: event type, should be `task.task.update_user_access_v2`
- `event_id`: unique event id (useful for dedup)
- `event_types`: list of commit types (see the deduped list above)
- `task_guid`: the task GUID that changed
- `timestamp`: event timestamp (ms)
In practice, this means:
- with `--as user`, the subscribed user can receive updates for tasks visible to them through authorship, assignment, or following
- with `--as bot`, the subscription covers tasks the application is responsible for
To actually receive the subscribed events, use the standard event WebSocket receiver:
```bash
lark-cli event +subscribe --event-types task.task.update_user_access_v2 --compact --quiet
```
The full flow is:
1. Register the subscription with `lark-cli task +subscribe-event [--as user|bot]`
2. Receive those events with `lark-cli event +subscribe --event-types task.task.update_user_access_v2 ...`
## Recommended Commands
```bash
lark-cli task +subscribe-event
```
# Subscribe with app identity
lark-cli task +subscribe-event --as bot
## Parameters
This shortcut has no additional parameters.
## Workflow
1. Confirm whether the user wants to subscribe with `user` identity or `bot` identity.
2. Execute `lark-cli task +subscribe-event`
3. Report whether the subscription succeeded, and clarify which identity the subscription applies to.
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -12,7 +12,7 @@ metadata:
> [!IMPORTANT]
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.12 -v`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 -v`,确认可用,无需询问用户。
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
@@ -24,11 +24,11 @@ metadata:
| 用户需求 | 行动 |
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 / 导出 SVG 矢量图 | [`+query --output_as image/svg`](references/lark-whiteboard-query.md) |
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 仅微调节点文字/颜色 | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
| 修改节点文字/颜色(简单改动) | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** Mermaid/PlantUML 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml`](references/lark-whiteboard-update.md) |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
@@ -36,8 +36,8 @@ metadata:
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、SVG 矢量图、代码或原始节点结构 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid 或 OpenAPI 原生格式 |
---

View File

@@ -336,7 +336,7 @@ DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignS
先出骨架图导出坐标,再基于坐标补充连线和注解:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.12 -i skeleton.json -o step1.png -l coords.json
npx -y @larksuite/whiteboard-cli@^0.2.11 -i skeleton.json -o step1.png -l coords.json
```
`coords.json` 包含每个带 id 节点的精确坐标absX, absY, width, height

View File

@@ -272,14 +272,14 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
x?: number; y?: number;
width?: WBSizeValue; // 默认 48
height?: WBSizeValue; // 默认 48保持正方形
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.12 --icons 输出中选取
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.11 --icons 输出中选取
color?: string; // 可选颜色覆盖hex 格式如 '#FF6600'
}
```
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.12 --icons
npx -y @larksuite/whiteboard-cli@^0.2.11 --icons
```
用法:

View File

@@ -2,21 +2,20 @@
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询画板内容,支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
查询画板内容,支持导出为预览图片、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
## 参数
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token需要拥有画板的读权限 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`SVG 矢量图)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as svg/code/raw` 时可选,不填则直接输出到终端 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as code/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `image`:预览图片
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `code`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
@@ -39,17 +38,7 @@ lark-cli whiteboard +query \
--output_as code
```
### 示例 3导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +query \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as svg \
--output ./whiteboard.svg \
--as user
```
### 示例 4导出画板原始节点结构到文件
### 示例 3导出画板原始节点结构到文件
```bash
lark-cli whiteboard +query \

View File

@@ -2,12 +2,11 @@
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
更新画板内容,支持种输入格式:
更新画板内容,支持种输入格式:
- `raw`:飞书 OpenAPI 原生画板节点格式,不推荐直接编辑。
- `plantuml`PlantUML 代码
- `mermaid`Mermaid 代码
- `svg`SVG 文本
输入内容可以通过管道从 stdin 读取,或通过 `--source` 指定文件。
@@ -19,7 +18,7 @@
| `--idempotent-token` | 否 | 幂等 token确保更新操作幂等最小长度 10 个字符 |
| `--overwrite` | 否 | 覆盖更新,在更新前删除所有现有内容,默认为 false |
| `--source` | 是 | 输入画板内容,支持使用 `@path` 从文件读取,或 `-` 从 stdin 读取 |
| `--input_format` | 否 | 输入格式:`raw``plantuml``mermaid``svg`,默认为 `raw` |
| `--input_format` | 否 | 输入格式:`raw``plantuml``mermaid`,默认为 `raw` |
### 以 raw (OpenAPI 原生画板节点格式) 创作
@@ -75,7 +74,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--source - --input_format raw \
@@ -89,7 +88,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 生成 OpenAPI 格式到文件
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <DSL 文件> --to openapi --format json -o ./temp.json
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <DSL 文件> --to openapi --format json -o ./temp.json
# 从文件读取并更新
lark-cli whiteboard +update \
@@ -99,24 +98,3 @@ lark-cli whiteboard +update \
--source @./temp.json \
--overwrite --as user
```
### 示例 5使用 SVG 写入画板(从文件读取)
适用于从零创建(直接写入 SVG和编辑现有画板编辑工作流详见 [`../routes/svg-edit.md`](../routes/svg-edit.md))。
```bash
# 编写或导出 SVG 文件
cat > diagram.svg << 'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100">
<rect x="10" y="10" width="80" height="40" fill="#4A90E2"/>
<text x="50" y="35" text-anchor="middle" fill="#fff">Hello</text>
</svg>
EOF
# 从文件读取并更新
lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--input_format svg \
--source @./diagram.svg \
--overwrite --as user
```

View File

@@ -29,11 +29,9 @@
+query --output_as code
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
→ +query --output_as image → 看图 → +query --output_as raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
│ → [`../routes/svg-edit.md`](../routes/svg-edit.md)(视觉高保真还原,大部分场景适用)
├─ 无代码DSL 或其他方式绘制的画板)
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
└─ 重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
└─ 用户有明确要求 → 以用户要求优先
```
@@ -81,7 +79,7 @@ diagram.png ← 渲染结果
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新。
```bash
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \

View File

@@ -13,7 +13,7 @@ Step 1: 路由 & 读取知识
Step 2: 生成完整 DSL含颜色
- 按 content.md 规划信息量和分组
- 按 layout.md 选择布局模式和间距
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.12 --icons` 查看可用图标
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 --icons` 查看可用图标
- 按 style.md 上色(用户没指定时用默认经典色板)
- 按 schema.md 语法输出完整 JSON
- 连线参考 connectors.md排版参考 typography.md
@@ -25,12 +25,12 @@ Step 2: 生成完整 DSL含颜色
Step 3: 渲染 & 审查 → 交付
- 渲染前自查(见下方检查清单)
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.json -o diagram.png
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json -o diagram.png
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.json --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -16,10 +16,10 @@ Step 3: 渲染验证 & 写入画板 & 交付
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
2. 保存为 diagram.mmd
3. 渲染仅用于预览验证PNG 不是最终产物):
npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.mmd -o diagram.png
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd -o diagram.png
4. 审查 PNG有问题修改后重新渲染最多 2 轮)
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@^0.2.12 -i diagram.mmd --to openapi --format json \
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -1,85 +0,0 @@
# SVG 编辑路径
通过导出画板的 SVG → 编辑 SVG → 回写画板,实现对已有画板的可视化编辑。
---
## ⚠️ 有损性警告
SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层级/表格结构/连线绑定/容器类型/mention/节点 ID/锁定/评论)会丢失。
**保留的信息**:形状几何(位置/大小/路径)、文本内容与基本格式(字号/粗体/斜体/对齐)、填充色/描边色/透明度(线性渐变降级为第一个 stop-color 纯色)、连接器路径形状与箭头样式、`<g>` 嵌套的基本分组关系≥2 子元素时重建为 DirectFocusGroup
---
## Workflow
### 0. 用户确认(强制)
在执行任何编辑前,**必须**向用户说明:
> SVG 编辑只保证视觉层面对齐,画板语义(层级/节点类型/思维导图结构/表格结构/连线绑定/容器类型/mention 等)将不可恢复,是否继续?
**用户未确认前不得执行后续步骤。**
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +query \
--whiteboard-token <TOKEN> \
--output_as svg \
--output <dir>/original.svg \
--as user
```
### 2. 编辑 SVG
在导出的 SVG 上进行修改。参考 [`svg.md` § 画板怎么处理 SVG](./svg.md#画板怎么处理-svg) 了解可识别元素与不支持的装饰特性。
**技术约束**
- 新增文字必须用 `<text>`(不是 `<path>`容器宽度留够CJK ≈ 1em / Latin ≈ 0.6em
- 避免 `skewX` / `skewY` / `matrix(...)` 变换
- 禁止使用 `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>`
**编辑原则**(区别于从零创作):
- **风格一致**:新增/修改元素应匹配导出 SVG 中已有的配色、字号、线宽、间距风格,不引入突兀的视觉差异
- **最小改动**:只修改用户要求的部分,不主动"优化"或重排无关区域
- **结构稳定**:尽量保留原有 `<g>` 层级结构,避免不必要的重组导致分组关系变化
- **连线协调**:连接器端点绑定已丢失,若移动了形状,必须手动同步调整视觉上连接到该形状的 connector path 端点坐标,否则连线会"断开"
- **内部引用完整性**:不要随意删改 `<defs>` 中被 `url(#id)` 引用的元素(`<marker>`/`<linearGradient>` 等)或修改其 `id`,否则引用方会失效
### 3. 渲染审查
```bash
# 渲染 PNG 预览
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/edited.svg -o <dir>/edited.png -f svg
# 几何检查text-overflow / node-overlap
npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/edited.svg -f svg --check
```
结合 PNG 视觉效果和 `--check` 报告进行调整,有问题则修改 SVG 后重新渲染(最多 2 轮)。
- SVG 本地渲染预览时,画板中的图片因 session 原因无法正常显示,属于预期内的行为。
### 4. 写回画板
`--overwrite` 会清空原画板内容,确认后再执行
```bash
# dry-run 探测
lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source @<dir>/edited.svg \
--input_format svg \
--idempotent-token <10+字符唯一串> \
--overwrite --dry-run --as user
# 用户确认后执行
lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source @<dir>/edited.svg \
--input_format svg \
--idempotent-token <10+字符唯一串> \
--overwrite --as user
```

View File

@@ -30,12 +30,12 @@
```
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
写文件 <dir>/diagram.svg
渲染 npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.12 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
渲染 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
```
`npx -y @larksuite/whiteboard-cli@^0.2.12 --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
`npx -y @larksuite/whiteboard-cli@^0.2.11 --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
## 画板怎么处理 SVG

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
## Layout 规则

View File

@@ -10,7 +10,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则

View File

@@ -9,7 +9,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.12` 渲染
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- 不适合手动心算坐标
## Layout 规则

View File

@@ -12,7 +12,6 @@
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessagesSendAudioDryRunRejectsNonOpus: proves the `im +messages-send --audio` dry-run validation rejects non-Opus local audio before upload, with typed validation metadata and recovery guidance.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
@@ -28,7 +27,7 @@
| ✓ | im +messages-reply | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/reply to message in thread as bot | `--message-id`; `--text`; `--reply-in-thread` | reply is read back via thread list |
| ✕ | im +messages-resources-download | shortcut | | none | needs a stable image/file message fixture plus file_key proof; left uncovered |
| ✕ | im +messages-search | shortcut | | none | freshly sent messages were not indexed deterministically in UAT time for a stable read-after-write proof |
| ✓ | im +messages-send | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/send message as user; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot; im/message_audio_dryrun_test.go::TestIM_MessagesSendAudioDryRunRejectsNonOpus | `--chat-id`; `--text`; `--audio ./voice.mp3 --dry-run` | live text sends feed follow-up reads; dry-run pins non-Opus audio validation before upload |
| ✓ | im +messages-send | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/send message as user; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot | `--chat-id`; `--text` | covered where returned message IDs feed follow-up reads |
| ✓ | im +threads-messages-list | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--thread` | proves threaded reply is persisted |
| ✕ | im chat.members create | api | | none | no member mutation workflow yet |
| ✕ | im chat.members get | api | | none | no member get workflow yet |

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessagesSendAudioDryRunRejectsNonOpus(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "im_audio_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_audio_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
workDir := t.TempDir()
audioPath := filepath.Join(workDir, "voice.mp3")
require.NoError(t, os.WriteFile(audioPath, []byte("not real mp3; validation checks extension before upload"), 0o600))
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+messages-send",
"--chat-id", "oc_123",
"--audio", "./voice.mp3",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
t.Fatalf("error.type = %q, want validation\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("error.subtype = %q, want invalid_argument\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.param").String(); got != "--audio" {
t.Fatalf("error.param = %q, want --audio\nstderr:\n%s", got, result.Stderr)
}
message := gjson.Get(result.Stderr, "error.message").String()
if !strings.Contains(message, "--audio supports only Opus audio files") {
t.Fatalf("error.message = %q, want Opus guidance\nstderr:\n%s", message, result.Stderr)
}
hint := gjson.Get(result.Stderr, "error.hint").String()
if !strings.Contains(hint, "--file") || !strings.Contains(hint, "ffmpeg") {
t.Fatalf("error.hint = %q, want --file and ffmpeg guidance\nstderr:\n%s", hint, result.Stderr)
}
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_GridlineWorkflow round-trips +sheet-show-gridline and
// +sheet-hide-gridline against a real spreadsheet. The dry-run E2E pins the
// wire shape; this live test validates the backend accepts both operations
// end-to-end (the gridline state itself is write-only — there is no read
// field exposed via +sheet-info / +workbook-info — so success here is the
// ok=true envelope, not a value comparison).
func TestSheets_GridlineWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
spreadsheetToken := createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-sheets-gridline-"+suffix, "bot")
infoResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
DefaultAs: "bot",
})
require.NoError(t, err)
infoResult.AssertExitCode(t, 0)
infoResult.AssertStdoutStatus(t, true)
sheetID := gjson.Get(infoResult.Stdout, "data.sheets.sheets.0.sheet_id").String()
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", infoResult.Stdout)
for _, shortcut := range []string{"+sheet-hide-gridline", "+sheet-show-gridline"} {
t.Run(shortcut+" as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", shortcut,
"--spreadsheet-token", spreadsheetToken,
"--sheet-id", sheetID,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
}

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_WorkbookExportDryRun pins the +workbook-export dry-run shape. It
// delegates to the shared drive export core but adds three sheet-specific
// guarantees that downstream agents rely on:
//
// 1. The doc type is hard-coded to "sheet" (drive +export would require
// --doc-type sheet explicitly).
// 2. csv mode routes the --sheet-id flag onto the export_tasks body as
// sub_id; xlsx mode omits sub_id.
// 3. The single --output-path flag collapses drive +export's --output-dir +
// --file-name pair onto the dry-run plan's output_dir / file_name extras.
func TestSheets_WorkbookExportDryRun(t *testing.T) {
t.Run("xlsx", func(t *testing.T) {
setSheetsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+workbook-export",
"--spreadsheet-token", "shtDryRunExport",
"--file-extension", "xlsx",
"--output-path", "./out.xlsx",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/drive/v1/export_tasks",
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "shtDryRunExport", gjson.Get(out, "api.0.body.token").String(), "stdout:\n%s", out)
require.Equal(t, "sheet", gjson.Get(out, "api.0.body.type").String(),
"workbook-export must hard-code type=sheet; stdout:\n%s", out)
require.Equal(t, "xlsx", gjson.Get(out, "api.0.body.file_extension").String(), "stdout:\n%s", out)
require.False(t, gjson.Get(out, "api.0.body.sub_id").Exists(),
"sub_id should be absent in xlsx mode; stdout:\n%s", out)
require.Equal(t, "./out.xlsx", gjson.Get(out, "output_dir").String(),
"--output-path carries through to the dry-run plan's top-level output_dir; stdout:\n%s", out)
})
t.Run("csv requires sheet-id and emits sub_id", func(t *testing.T) {
setSheetsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+workbook-export",
"--spreadsheet-token", "shtDryRunExport",
"--file-extension", "csv",
"--sheet-id", "sheet1",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "csv", gjson.Get(out, "api.0.body.file_extension").String(), "stdout:\n%s", out)
require.Equal(t, "sheet1", gjson.Get(out, "api.0.body.sub_id").String(),
"--sheet-id must reach sub_id in csv mode; stdout:\n%s", out)
})
t.Run("csv without sheet-id is rejected", func(t *testing.T) {
setSheetsDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+workbook-export",
"--spreadsheet-token", "shtDryRunExport",
"--file-extension", "csv",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
require.NotEqual(t, 0, result.ExitCode,
"csv export without --sheet-id should surface a validation error; stdout:\n%s\nstderr:\n%s",
result.Stdout, result.Stderr)
})
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSheets_WorkbookImportWorkflow round-trips +workbook-import end to end:
// write a local CSV → import as a new Feishu spreadsheet → assert the import
// task finished (ready=true) with a sheet token → +info confirms the new
// workbook is reachable → cleanup deletes the spreadsheet.
//
// The dry-run E2E in sheets_workbook_import_dryrun_test.go pins the two-step
// request shape (media upload + import task with type=sheet); this live test
// validates the full flow including the async poll and that the resulting
// token is a usable sheet token.
func TestSheets_WorkbookImportWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
// CLI sandbox only accepts relative file paths under cwd; write the CSV
// into a TempDir and hand RunCmd that as WorkDir so --file resolves.
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "data.csv"),
[]byte("Name,Age,City\nAlice,25,Beijing\nBob,30,Shanghai\n"), 0o644))
suffix := clie2e.GenerateSuffix()
title := "lark-cli-e2e-sheets-import-" + suffix
folderToken := drive.CreateDriveFolder(t, parentT, ctx, title+"-folder", "bot", "")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+workbook-import",
"--file", "data.csv",
"--name", title,
"--folder-token", folderToken,
},
DefaultAs: "bot",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.True(t, gjson.Get(result.Stdout, "data.ready").Bool(),
"import task should be ready within poll window; stdout:\n%s", result.Stdout)
assert.Equal(t, "sheet", gjson.Get(result.Stdout, "data.type").String(),
"workbook-import hard-codes type=sheet; stdout:\n%s", result.Stdout)
spreadsheetToken := gjson.Get(result.Stdout, "data.token").String()
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty; stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, spreadsheetToken, "sheet", "bot")
clie2e.ReportCleanupFailure(parentT, "delete imported spreadsheet "+spreadsheetToken, deleteResult, deleteErr)
})
// Sanity: the imported token resolves through the sheets read path.
infoResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
DefaultAs: "bot",
})
require.NoError(t, err)
infoResult.AssertExitCode(t, 0)
infoResult.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(infoResult.Stdout, "data.sheets.sheets.0.sheet_id").Exists(),
"imported workbook should expose at least one sub-sheet; stdout:\n%s", infoResult.Stdout)
}