Compare commits

..

37 Commits

Author SHA1 Message Date
zhanghuanxu
d79b2b499d feat: inline slides reference docs into help output
Embed the lark-slides reference docs (xml-schema-quick-ref.md and each shortcut's command guide) directly into the CLI help output so that agents can discover XML syntax and shortcut usage without reading separate markdown files.

- PrepareDomainHelp: append XML schema quick reference for slides domain
- PrepareShortcutHelp: embed shortcut-specific reference docs for +create, +xml-get, +screenshot, +media-upload, +replace-slide, +replace-pages, +history-list, +history-revert, +history-revert-status
- Add contract tests for all reference mappings and re-render idempotency
- +screenshot excludes the shared XML reference (user feedback)
2026-08-02 21:56:52 +08:00
zhanghuanxu
e0bc29a648 fix: detect labeled metric text overflow 2026-07-30 15:08:08 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
625 changed files with 27256 additions and 7202 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -99,22 +99,6 @@ jobs:
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
windows-compat:
needs: fast-gate
runs-on: windows-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python scripts/fetch_meta.py
- name: Run Windows compatibility tests
run: go test -count=1 -timeout=5m . ./shortcuts/doc/...
lint:
needs: fast-gate
runs-on: ubuntu-latest

View File

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

1
.gitignore vendored
View File

@@ -42,7 +42,6 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-cli-e2e-test/reports/
.lark-slides/
/notes/
/minutes/

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,90 @@
All notable changes to this project will be documented in this file.
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
@@ -1638,6 +1722,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -1,105 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"runtime"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// BenchmarkBuild_Default measures the per-Build cost for the default
// configuration (service commands + shortcuts + plugins + strict mode).
// This is the hot-path baseline for repeated Build invocations.
func BenchmarkBuild_Default(b *testing.B) {
// Warm one-time caches first
_ = Build(context.Background(), cmdutil.InvocationContext{})
runtime.GC()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = Build(context.Background(), cmdutil.InvocationContext{})
}
}
// BenchmarkBuild_WithoutServiceCommands measures the Build cost without
// service command registration. The delta from Default gives the
// service-command registration cost.
func BenchmarkBuild_WithoutServiceCommands(b *testing.B) {
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
runtime.GC()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands())
}
}
// BenchmarkBuild_WithoutPlugins measures the Build cost without plugins.
// The delta from Default gives the plugin + policy + hook cost.
func BenchmarkBuild_WithoutPlugins(b *testing.B) {
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
runtime.GC()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
}
}
// BenchmarkBuild_WithoutServiceAndPlugins measures the Build cost with
// neither service commands nor plugins. This isolates the base cost
// (root command + builtins + shortcuts).
func BenchmarkBuild_WithoutServiceAndPlugins(b *testing.B) {
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
runtime.GC()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = Build(context.Background(), cmdutil.InvocationContext{}, WithoutServiceCommands(), WithoutPlugins())
}
}
// TestBuild_CommandTreeStats counts the total number of commands,
// runnable commands, and flags in the default build. This gives us
// the scale of the command tree to reason about optimization targets.
func TestBuild_CommandTreeStats(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
var totalCmds, runnableCmds, groupCmds int
var totalFlags int
var walk func(cmd *cobra.Command)
walk = func(cmd *cobra.Command) {
totalCmds++
if cmd.RunE != nil || cmd.Run != nil {
runnableCmds++
} else {
groupCmds++
}
if cmd.Flags() != nil {
cmd.Flags().VisitAll(func(f *pflag.Flag) {
totalFlags++
})
}
for _, child := range cmd.Commands() {
walk(child)
}
}
walk(root)
t.Logf("Command tree stats:")
t.Logf(" Total commands: %d", totalCmds)
t.Logf(" Runnable commands: %d", runnableCmds)
t.Logf(" Group commands: %d", groupCmds)
t.Logf(" Total flags: %d", totalFlags)
}

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -66,6 +66,7 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
appendSlidesXMLQuickReference(&b, cmd, skillFS)
cmd.Long = b.String()
return true
}
@@ -116,6 +117,90 @@ const (
shortcutBaseAnnotation = "affordance-shortcut-base"
)
const slidesXMLQuickReferencePath = "lark-slides/references/xml-schema-quick-ref.md"
// slidesShortcutReferencePaths maps each Slides shortcut to its primary
// command guide. XML-consuming shortcuts also include the shared schema
// reference because their command guide accepts XML but does not repeat the
// complete element grammar.
var slidesShortcutReferencePaths = map[string][]string{
"+create": {
"lark-slides/references/lark-slides-create.md",
slidesXMLQuickReferencePath,
},
"+xml-get": {
"lark-slides/references/lark-slides-xml-presentations-get.md",
},
"+screenshot": {
"lark-slides/references/lark-slides-screenshot.md",
},
"+media-upload": {
"lark-slides/references/lark-slides-media-upload.md",
},
"+replace-slide": {
"lark-slides/references/lark-slides-replace-slide.md",
"lark-slides/references/lark-slides-edit-workflows.md",
slidesXMLQuickReferencePath,
},
"+replace-pages": {
"lark-slides/references/lark-slides-replace-pages.md",
"lark-slides/references/lark-slides-edit-workflows.md",
slidesXMLQuickReferencePath,
},
"+history-list": {
"lark-slides/references/lark-slides-history.md",
},
"+history-revert": {
"lark-slides/references/lark-slides-history.md",
},
"+history-revert-status": {
"lark-slides/references/lark-slides-history.md",
},
}
// appendSlidesXMLQuickReference adds the embedded XML schema summary to the
// slides domain help. The reference file is already shipped in the skill
// content tree, so help and the standalone skill reader share one source of
// truth instead of maintaining a second, drifting copy in Go.
func appendSlidesXMLQuickReference(b *strings.Builder, cmd *cobra.Command, skillFS fs.FS) {
if cmd.Name() != "slides" || skillFS == nil {
return
}
content, err := fs.ReadFile(skillFS, slidesXMLQuickReferencePath)
if err != nil || len(content) == 0 {
return
}
b.WriteString("\n\nEmbedded XML syntax quick reference:\n")
b.Write(content)
}
func readSlidesShortcutReferences(cmd *cobra.Command, skillFS fs.FS) ([]string, bool) {
if cmdmeta.Domain(cmd) != "slides" || skillFS == nil {
return nil, false
}
paths, ok := slidesShortcutReferencePaths[cmd.Name()]
if !ok {
return nil, false
}
var contents []string
for _, path := range paths {
content, err := fs.ReadFile(skillFS, path)
if err != nil || len(content) == 0 {
continue
}
contents = append(contents, fmt.Sprintf("Embedded command reference: %s\n%s", path, content))
}
return contents, len(contents) > 0
}
func appendSlidesShortcutReferences(b *strings.Builder, contents []string) {
for _, content := range contents {
b.WriteString("\n\n")
b.WriteString(content)
}
}
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
@@ -171,11 +256,11 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
// overlay and any embedded command references — the same top layout as method
// help (description, Risk, guidance block, related skills) minus the schema
// pointer, which shortcuts have none of. Returns false when the command is not
// a shortcut, or when it has neither an overlay nor an embedded reference, so
// ordinary shortcuts keep the default help plus the bottom risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
@@ -191,12 +276,17 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
references, hasReferences := readSlidesShortcutReferences(cmd, skillFS)
var a meta.Affordance
hasAffordance := false
if raw, ok := affordanceRaw(cmd); ok {
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
a = parsed
hasAffordance = true
}
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
if !hasAffordance && !hasReferences {
return false
}
if len(a.Tips) == 0 {
@@ -211,6 +301,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
appendSlidesShortcutReferences(&b, references)
cmd.Long = b.String()
return true

View File

@@ -264,6 +264,94 @@ func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
}
}
func TestPrepareShortcutHelp_SlidesReferenceWithoutAffordance(t *testing.T) {
sc := &cobra.Command{Use: "+xml-get", Short: "Fetch presentation XML"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetDomain(sc, "slides")
cmdmeta.SetAffordanceRef(sc, "slides", "+xml-get")
cmdutil.SetRisk(sc, "read")
skillFS := fstest.MapFS{
"lark-slides/references/lark-slides-xml-presentations-get.md": {
Data: []byte("# slides +xml-get\n\nRead the presentation XML."),
},
}
if !PrepareShortcutHelp(sc, skillFS) {
t.Fatal("PrepareShortcutHelp returned false for a Slides shortcut with an embedded reference")
}
for _, want := range []string{
"Fetch presentation XML",
"Risk: read",
"Embedded command reference: lark-slides/references/lark-slides-xml-presentations-get.md",
"Read the presentation XML.",
} {
if !strings.Contains(sc.Long, want) {
t.Errorf("Slides shortcut help missing %q:\n%s", want, sc.Long)
}
}
PrepareShortcutHelp(sc, skillFS)
if got := strings.Count(sc.Long, "Embedded command reference:"); got != 1 {
t.Fatalf("embedded reference appended %d times after re-render, want 1:\n%s", got, sc.Long)
}
}
func TestSlidesShortcutReferenceMapping(t *testing.T) {
want := map[string]string{
"+create": "lark-slides/references/lark-slides-create.md",
"+xml-get": "lark-slides/references/lark-slides-xml-presentations-get.md",
"+screenshot": "lark-slides/references/lark-slides-screenshot.md",
"+media-upload": "lark-slides/references/lark-slides-media-upload.md",
"+replace-slide": "lark-slides/references/lark-slides-replace-slide.md",
"+replace-pages": "lark-slides/references/lark-slides-replace-pages.md",
"+history-list": "lark-slides/references/lark-slides-history.md",
"+history-revert": "lark-slides/references/lark-slides-history.md",
"+history-revert-status": "lark-slides/references/lark-slides-history.md",
}
for command, path := range want {
t.Run(command, func(t *testing.T) {
sc := &cobra.Command{Use: command, Short: command}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetDomain(sc, "slides")
skillFS := fstest.MapFS{
path: {Data: []byte("reference content")},
}
contents, ok := readSlidesShortcutReferences(sc, skillFS)
if !ok || len(contents) == 0 {
t.Fatalf("shortcut %q has no mapped reference", command)
}
if !strings.Contains(contents[0], "Embedded command reference: "+path) {
t.Fatalf("shortcut %q mapped content does not include %q:\n%s", command, path, contents[0])
}
})
}
}
func TestSlidesScreenshotHelpDoesNotIncludeXMLQuickReference(t *testing.T) {
sc := &cobra.Command{Use: "+screenshot", Short: "Save screenshots"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetDomain(sc, "slides")
skillFS := fstest.MapFS{
"lark-slides/references/lark-slides-screenshot.md": {
Data: []byte("# slides +screenshot\n\nSave screenshots."),
},
slidesXMLQuickReferencePath: {
Data: []byte("# XML Schema Quick Reference"),
},
}
contents, ok := readSlidesShortcutReferences(sc, skillFS)
if !ok {
t.Fatal("screenshot shortcut should have a primary reference")
}
if len(contents) != 1 {
t.Fatalf("screenshot reference count = %d, want 1: %#v", len(contents), contents)
}
if strings.Contains(contents[0], "XML Schema Quick Reference") {
t.Fatalf("screenshot help must not include the XML quick reference:\n%s", contents[0])
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
@@ -306,3 +394,51 @@ func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
}
}
func TestPrepareDomainHelp_SlidesIncludesEmbeddedXMLReference(t *testing.T) {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "slides", Short: "Slides"}
cmdmeta.SetDomain(dom, "slides")
dom.AddCommand(&cobra.Command{Use: "+create", Short: "Create", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
const quickReference = `# XML Schema Quick Reference
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title"><p>Title</p></content>
</shape>
</data>
</slide>
</presentation>
<table><colgroup><col/></colgroup><tr><td><content><p>A</p></content></td></tr></table>
<chart><chartPlotArea/><chartData/></chart>`
skillFS := fstest.MapFS{
"lark-slides/SKILL.md": {Data: []byte("# slides")},
"lark-slides/references/xml-schema-quick-ref.md": {Data: []byte(quickReference)},
}
if !PrepareDomainHelp(dom, skillFS) {
t.Fatal("PrepareDomainHelp returned false for slides domain")
}
for _, want := range []string{
"Embedded XML syntax quick reference:",
`<presentation xmlns="http://www.larkoffice.com/sml/2.0"`,
"<shape type=\"text\"",
"<content",
"topLeftX",
"<table>",
"<chart>",
} {
if !strings.Contains(dom.Long, want) {
t.Errorf("slides help missing XML reference marker %q:\n%s", want, dom.Long)
}
}
PrepareDomainHelp(dom, skillFS)
if got := strings.Count(dom.Long, "Embedded XML syntax quick reference:"); got != 1 {
t.Fatalf("slides XML reference appended %d times after re-render, want 1:\n%s", got, dom.Long)
}
}

View File

@@ -42,15 +42,6 @@ type FileIO interface {
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
}
// TempDirFileCreator is an optional FileIO capability for atomically creating
// a unique directory and an empty named file inside it. The directory pattern
// follows os.MkdirTemp semantics: the last '*' is replaced with a random
// value. Implementations return a relative file path that can be passed back
// to FileIO.
type TempDirFileCreator interface {
CreateTempDirFile(directoryPattern, fileName string) (string, error)
}
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
// os.FileInfo satisfies this interface.
type FileInfo interface {

1
go.mod
View File

@@ -18,7 +18,6 @@ require (
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
github.com/yuin/goldmark v1.7.16
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.33.0
golang.org/x/sync v0.15.0

2
go.sum
View File

@@ -131,8 +131,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -26,6 +26,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}

View File

@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// Agent headers injected via BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
}
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -5,14 +5,10 @@ package localfileio
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
)
@@ -34,8 +30,6 @@ func init() {
// and atomic writes are handled internally.
type LocalFileIO struct{}
var _ fileio.TempDirFileCreator = (*LocalFileIO)(nil)
// Open opens a local file for reading after validating the path.
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
safePath, err := SafeInputPath(name)
@@ -68,46 +62,6 @@ func (l *LocalFileIO) ResolvePath(path string) (string, error) {
return resolved, nil
}
// CreateTempDirFile atomically creates a unique directory in the current
// working directory, then creates the requested empty file inside it.
func (l *LocalFileIO) CreateTempDirFile(directoryPattern, fileName string) (string, error) {
if err := validateTempDirectoryPattern(directoryPattern); err != nil {
return "", &fileio.PathValidationError{Err: err}
}
if err := validateTempFileName(fileName); err != nil {
return "", &fileio.PathValidationError{Err: err}
}
tempDir, err := vfs.MkdirTemp(".", directoryPattern)
if err != nil {
return "", &fileio.MkdirError{Err: err}
}
path := filepath.Join(tempDir, fileName)
tempFile, err := vfs.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
_ = vfs.RemoveAll(tempDir)
return "", &fileio.WriteError{Err: err}
}
if err := tempFile.Close(); err != nil {
_ = vfs.RemoveAll(tempDir)
return "", &fileio.WriteError{Err: fmt.Errorf("close temporary file: %w", err)}
}
return filepath.Join(filepath.Base(tempDir), fileName), nil
}
func validateTempDirectoryPattern(pattern string) error {
if strings.TrimSpace(pattern) == "" || strings.ContainsAny(pattern, `/\\`) || strings.Count(pattern, "*") != 1 {
return fmt.Errorf("temporary directory pattern must be one non-empty path component containing exactly one '*'")
}
return charcheck.RejectControlChars(pattern, "temporary directory pattern")
}
func validateTempFileName(fileName string) error {
if strings.TrimSpace(fileName) == "" || fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "/\\\t\r\n") {
return fmt.Errorf("temporary file name must be one non-empty path component")
}
return charcheck.RejectControlChars(fileName, "temporary file name")
}
// Save writes body to path atomically after validating the output path.
// Parent directories are created as needed. The body is streamed directly
// to a temp file and renamed, avoiding full in-memory buffering.

View File

@@ -4,12 +4,10 @@
package localfileio
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/extension/fileio"
@@ -252,81 +250,6 @@ func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
}
}
func TestLocalFileIO_CreateTempDirFileIsUniqueUnderConcurrency(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
const count = 32
type result struct {
path string
err error
}
results := make(chan result, count)
var wg sync.WaitGroup
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
path, err := (&LocalFileIO{}).CreateTempDirFile("川西_*_folder", "川西.xml")
results <- result{path: path, err: err}
}()
}
wg.Wait()
close(results)
seen := make(map[string]struct{}, count)
for result := range results {
if result.err != nil {
t.Fatalf("CreateTempDirFile failed: %v", result.err)
}
directory := filepath.Dir(result.path)
if filepath.Base(result.path) != "川西.xml" || filepath.Base(directory) != directory ||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
t.Fatalf("CreateTempDirFile path = %q, want 川西_<random>_folder/川西.xml", result.path)
}
if _, ok := seen[directory]; ok {
t.Fatalf("CreateTempDirFile returned duplicate directory %q", directory)
}
seen[directory] = struct{}{}
info, err := os.Stat(result.path)
if err != nil {
t.Fatalf("stat temporary file %q: %v", result.path, err)
}
if info.Size() != 0 {
t.Fatalf("temporary file %q size = %d, want 0", result.path, info.Size())
}
}
if len(seen) != count {
t.Fatalf("unique temporary files = %d, want %d", len(seen), count)
}
}
func TestLocalFileIO_CreateTempDirFileRejectsUnsafeComponents(t *testing.T) {
dir := t.TempDir()
testChdir(t, dir)
fio := &LocalFileIO{}
for _, test := range []struct {
pattern string
fileName string
}{
{pattern: "../lark-doc-*", fileName: "draft.xml"},
{pattern: "lark-doc-*", fileName: "../draft.xml"},
{pattern: "lark-doc-*", fileName: `folder\draft.xml`},
} {
if _, err := fio.CreateTempDirFile(test.pattern, test.fileName); !errors.Is(err, fileio.ErrPathValidation) {
t.Errorf("CreateTempDirFile(%q, %q) error = %v, want path validation", test.pattern, test.fileName, err)
}
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read work directory: %v", err)
}
if len(entries) != 0 {
t.Fatalf("invalid inputs created files: %+v", entries)
}
}
// ── Error message consistency ──
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.80",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.80",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
if grep -Fq 'run.name !== "CI"' "$workflow"; then
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"

View File

@@ -4,6 +4,7 @@
package base
import (
"encoding/json"
"strings"
"testing"
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
"total": 2,
"questions": []interface{}{
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
},
},
},
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
got := stdout.String()
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
t.Fatalf("stdout=%s", got)
}
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
if !strings.Contains(got, `"visible_rule"`) {
t.Fatalf("visible_rule missing from list output: %s", got)
}
}
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
t.Fatalf("expected error for invalid questions JSON")
}
})
t.Run("visible_rule passthrough", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
},
},
},
}
reg.Register(stub)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body struct {
Questions []map[string]interface{} `json:"questions"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
}
if len(body.Questions) != 1 {
t.Fatalf("questions=%#v", body.Questions)
}
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
if !ok {
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
}
if rule["logic"] != "and" {
t.Fatalf("visible_rule logic not preserved: %#v", rule)
}
})
}
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
stub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
},
},
},
})
}
reg.Register(stub)
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
t.Fatalf("stdout=%s", got)
}
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
var body struct {
Questions []map[string]interface{} `json:"questions"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
}
if len(body.Questions) != 1 {
t.Fatalf("questions=%#v", body.Questions)
}
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
}
}
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {

View File

@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
api := common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")

View File

@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
},
Tips: []string{
"Update uses full question overwrite semantics, not a patch.",
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
api := common.NewDryRunAPI().
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")

View File

@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "form question create visible_rule",
shortcut: BaseFormQuestionsCreate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
name: "form question update visible_rule",
shortcut: BaseFormQuestionsUpdate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
name: "record search json",
shortcut: BaseRecordSearch,
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"Update uses full question overwrite semantics",
"run +form-questions-list first",
"include existing values you want to keep",
"pass null or omit to clear",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
"full question overwrite semantics, not a patch",
"Run +form-questions-list first",
"title/description/required/option_display_mode/visible_rule",
"Omitted fields reset to defaults",
"empty strings, null, and empty arrays are written as empty/clear",
}
for _, want := range wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string

View File

@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -20,7 +20,6 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}
eventData := buildEventData(runtime, startTs, endTs)

View File

@@ -81,6 +81,7 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
collapseDescription(out)
return out, nil
}

View File

@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
if _, ok := body["description"]; ok {
t.Fatalf("plain description must not be sent, got: %#v", body)
}
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
}
}
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"status": "confirmed",
"description": "[测试]\n友情提醒",
"description_rich": "友情提醒",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"status": "confirmed",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
},
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
func TestAgenda_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"description": "[表格]",
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body := map[string]interface{}{}
hasFields := false
for _, field := range []string{"summary", "description"} {
if runtime.Cmd.Flags().Changed(field) {
body[field] = runtime.Str(field)
hasFields = true
}
if runtime.Cmd.Flags().Changed("summary") {
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
if description, _ := event["description"].(string); description != "" {
result["description"] = description
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"fmt"
"image"
// Register the common image decoders so DecodeConfig can read intrinsic
// dimensions for PNG/JPEG/GIF sources.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
if err != nil {
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
}
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
if len(matches) == 0 {
return md, false, nil
}
var out strings.Builder
last := 0
changed := false
cache := map[string]string{}
for _, m := range matches {
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
src := strings.TrimSpace(md[srcStart:srcEnd])
if !isLocalImageSrc(src) {
continue
}
alt := md[altStart:altEnd]
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
if err != nil {
return "", false, err
}
out.WriteString(md[last:srcStart])
out.WriteString(uploadedURL)
last = srcEnd
changed = true
}
if !changed {
return md, false, nil
}
out.WriteString(md[last:])
return out.String(), true, nil
}
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
localPath := localImagePath(src)
if cached, ok := cache[localPath]; ok {
return cached, nil
}
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
info, err := runtime.FileIO().Stat(localPath)
if err != nil {
return "", common.WrapInputStatErrorTyped(err)
}
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: localPath,
FileName: filepath.Base(safePath),
FileSize: info.Size(),
ParentType: calendarMediaParentType,
ParentNode: &calendarID,
})
if err != nil {
return "", err
}
width, height := decodeImageDimensions(runtime, localPath)
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
cache[localPath] = uploadedURL
return uploadedURL, nil
}
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return 0, 0
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0
}
return cfg.Width, cfg.Height
}
func isLocalImageSrc(src string) bool {
if src == "" {
return false
}
lower := strings.ToLower(src)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
return false
case strings.HasPrefix(lower, "file://"):
return true
}
if i := strings.Index(src, "://"); i > 0 {
return false
}
return true
}
func localImagePath(src string) string {
s := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(s), "file://") {
if u, err := url.Parse(s); err == nil && u.Path != "" {
s = u.Path
}
}
if decoded, err := url.PathUnescape(s); err == nil {
return decoded
}
return s
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.feishu.cn"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
if width > 0 && height > 0 {
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
}
if size > 0 {
u += fmt.Sprintf("&im_size=%d", size)
}
return u
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"encoding/json"
"errors"
"image"
"image/png"
"net/url"
"os"
"path/filepath"
"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"
)
func TestIsLocalImageSrc(t *testing.T) {
cases := []struct {
src string
want bool
}{
{"./images/pic.png", true},
{"images/pic.png", true},
{"../assets/a.png", true},
{"/Users/me/Desktop/a.png", true},
{`C:\Users\me\a.png`, true},
{"file:///Users/me/a.png", true},
{"图片和附件/测试图片.png", true},
{"https://example.com/a.png", false},
{"http://example.com/a.png", false},
{"HTTPS://EXAMPLE.com/a.png", false},
{"data:image/png;base64,iVBOR", false},
{"ftp://host/a.png", false},
{"", false},
}
for _, c := range cases {
if got := isLocalImageSrc(c.src); got != c.want {
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestLocalImagePath(t *testing.T) {
cases := []struct{ in, want string }{
{"images/pic.png", "images/pic.png"},
{"images/my%20pic.png", "images/my pic.png"},
{"file:///Users/me/a.png", "/Users/me/a.png"},
}
for _, c := range cases {
if got := localImagePath(c.in); got != c.want {
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
// relies on: a Lark host (so token extraction triggers) whose final path
// segment is exactly the uploaded file token.
func TestBuildCalendarImagePreviewURL(t *testing.T) {
for _, tc := range []struct {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "feishu.cn"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("built URL not parseable: %v", err)
}
if !strings.Contains(u.Host, tc.hostFrag) {
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
t.Errorf("last path segment = %q, want token", last)
}
q := u.Query()
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
}
}
// With unknown dimensions the helper params are omitted entirely.
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
t.Errorf("expected no dimension params for unknown size, got %q", raw)
}
}
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
// pass through unchanged and never trigger an upload (runtime unused → nil).
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
md := "text ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)"
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if changed {
t.Errorf("changed = true, want false")
}
if got != md {
t.Errorf("markdown mutated: %q", got)
}
}
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
// mocks the drive upload, and asserts the create body's description_rich carries
// the uploaded token (not the local path).
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
}
reg.Register(uploadStub)
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
if uploadStub.CapturedBody == nil {
t.Fatalf("expected drive upload to be called")
}
if createStub.CapturedBody == nil {
t.Fatalf("expected create event to be called")
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "boxcnTOKEN123") {
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
}
if strings.Contains(dr, "./pic.png") {
t.Fatalf("local path should be rewritten away, got %q", dr)
}
}
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
// intrinsic width/height and byte size are appended to the rewritten drive URL
// (so the facade can populate originalWidth/originalHeight and the client can
// render the image inline).
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
}
if !strings.Contains(dr, "im_size=") {
t.Fatalf("description_rich should carry image byte size, got %q", dr)
}
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
t.Fatalf("expected error for absolute image path")
}
var ve *errs.ValidationError
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}
func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false

View File

@@ -1,418 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
)
const (
docsScriptParse = "parse"
docsScriptMarkdownToXML = "markdown-to-xml"
docsScriptCreateTempXML = "create-temp-xml"
docsScriptTempDirSuffix = "_*_folder"
)
var DocsScript = common.Shortcut{
Service: "docs",
Command: "+script",
Description: "Create a unique temporary XML file, parse and profile local or online documents, or convert Markdown to LarkOpenCLI XML",
Risk: "read",
AuthTypes: []string{"user", "bot"},
Scopes: []string{},
ConditionalScopes: []string{
"docx:document:readonly",
},
Flags: []common.Flag{
{
Name: "command",
Desc: "local document operation",
Required: true,
Enum: []string{docsScriptParse, docsScriptMarkdownToXML, docsScriptCreateTempXML},
},
{
Name: "content",
Desc: "local content for parse or markdown-to-xml; use @relative-file or - for stdin; mutually exclusive with --doc",
Input: []string{common.File, common.Stdin},
},
{
Name: "doc",
Desc: "online document URL or token for --command parse; mutually exclusive with --content",
},
{
Name: "output",
Desc: "local XML output path for markdown-to-xml; omit to return XML in data.xml",
},
{
Name: "file-name",
Desc: "portable base name without .xml; create-temp-xml writes <name>_<random>_folder/<name>.xml",
},
{
Name: "overwrite",
Type: "bool",
Desc: "overwrite an existing --output file",
},
},
Tips: []string{
"create-temp-xml atomically creates <file-name>_<random>_folder/<file-name>.xml in the current directory",
"parse accepts local --content or an online --doc URL/token and returns only the text and block profile",
"markdown-to-xml converts Markdown to LarkOpenCLI XML",
"use --output to save converted XML directly and keep stdout compact",
},
PostMount: installDocsScriptHelp,
Validate: validateDocsScript,
DryRun: dryRunDocsScript,
Execute: executeDocsScript,
}
type docsScriptParseResult struct {
Profile docsScriptPublicProfile `json:"profile"`
}
// docsScriptPublicProfile is the stable shortcut response. The parser keeps
// the more detailed breakdown internally so it can be exposed later without
// changing the counting implementation.
type docsScriptPublicProfile struct {
WordCount int `json:"word_count"`
CharCount int `json:"char_count"`
BlockCount int `json:"block_count"`
Blocks []docxparse.BlockShare `json:"blocks"`
}
type docsScriptMarkdownResult struct {
XML string `json:"xml"`
}
type docsScriptMarkdownFileResult struct {
SavedPath string `json:"saved_path"`
SizeBytes int64 `json:"size_bytes"`
}
type docsScriptTempXMLResult struct {
Path string `json:"path"`
}
func installDocsScriptHelp(cmd *cobra.Command) {
installDocsShortcutHelp("+script")(cmd)
cmd.Example = ` lark-cli docs +script --command create-temp-xml --file-name "draft"
lark-cli docs +script --command parse --content "@draft.xml"
lark-cli docs +script --command parse --content "@draft.md"
lark-cli docs +script --command parse --doc "https://example.larksuite.com/docx/doxcn..."
lark-cli docs +script --command markdown-to-xml --content "@draft.md" --output "draft.xml"`
}
func validateDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
content := strings.TrimSpace(runtime.Str("content"))
doc := strings.TrimSpace(runtime.Str("doc"))
outputPath := strings.TrimSpace(runtime.Str("output"))
fileName := strings.TrimSpace(runtime.Str("file-name"))
if runtime.Str("command") == docsScriptCreateTempXML {
switch {
case content != "":
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--content is not supported with --command create-temp-xml").WithParam("--content")
case doc != "":
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--doc is not supported with --command create-temp-xml").WithParam("--doc")
case outputPath != "":
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output is not supported with --command create-temp-xml").WithParam("--output")
case runtime.Bool("overwrite"):
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--overwrite is not supported with --command create-temp-xml").WithParam("--overwrite")
case fileName == "":
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name is required with --command create-temp-xml").WithParam("--file-name")
case runtime.Str("file-name") != fileName:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name must not start or end with whitespace").WithParam("--file-name")
default:
return validateDocsScriptTempXMLFileName(fileName)
}
}
if fileName != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name is only supported with --command create-temp-xml").WithParam("--file-name")
}
if content == "" && doc == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --content or --doc is required").WithParams(
errs.InvalidParam{Name: "--content", Reason: "provide local document content"},
errs.InvalidParam{Name: "--doc", Reason: "provide an online document URL or token"},
)
}
if content != "" && doc != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content and --doc are mutually exclusive").WithParams(
errs.InvalidParam{Name: "--content", Reason: "mutually exclusive with --doc"},
errs.InvalidParam{Name: "--doc", Reason: "mutually exclusive with --content"},
)
}
if doc != "" {
if runtime.Str("command") != docsScriptParse {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--doc is only supported with --command parse").WithParam("--doc")
}
if _, err := parseDocumentRef(doc); err != nil {
return err
}
if err := runtime.EnsureScopes([]string{"docx:document:readonly"}); err != nil {
return err
}
}
if outputPath == "" {
if runtime.Bool("overwrite") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--overwrite requires --output").WithParam("--overwrite")
}
return nil
}
if runtime.Str("command") != docsScriptMarkdownToXML {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output is only supported with --command markdown-to-xml").WithParam("--output")
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
WithParam("--output").
WithCause(err)
}
return nil
}
func dryRunDocsScript(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Str("command") == docsScriptCreateTempXML {
fileName := strings.TrimSpace(runtime.Str("file-name"))
return common.NewDryRunAPI().
Desc("Create a random directory and an empty named XML file inside it; no API call is made").
Set("command", docsScriptCreateTempXML).
Set("directory_pattern", docsScriptTempDirectoryPattern(fileName)).
Set("file_name", fileName).
Set("xml_file_name", docsScriptXMLFileName(fileName)).
Set("creates_file", false).
Set("network", false)
}
if doc := strings.TrimSpace(runtime.Str("doc")); doc != "" {
ref, _ := parseDocumentRef(doc)
return common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents/:document_id/fetch").
Desc("OpenAPI: fetch document for parsing and profiling").
Body(docsScriptFetchBody(runtime)).
Set("command", runtime.Str("command")).
Set("document_id", ref.Token).
Set("network", true)
}
dry := common.NewDryRunAPI().
Desc("Local LarkOpenCLI document parsing or conversion; no API call is made").
Set("command", runtime.Str("command")).
Set("input_bytes", len(runtime.Str("content"))).
Set("network", false)
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
dry.Set("output", outputPath).Set("overwrite", runtime.Bool("overwrite"))
}
return dry
}
func executeDocsScript(_ context.Context, runtime *common.RuntimeContext) error {
command := runtime.Str("command")
content := runtime.Str("content")
switch command {
case docsScriptCreateTempXML:
return createDocsScriptTempXML(runtime)
case docsScriptParse:
inputParam := "--content"
inputLabel := "--content"
if strings.TrimSpace(runtime.Str("doc")) != "" {
var err error
content, err = fetchDocsScriptContent(runtime)
if err != nil {
return err
}
inputParam = "--doc"
inputLabel = "fetched document content"
}
profile, err := docxparse.ParseAuto(content)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not parse %s as LarkOpenCLI XML or Markdown: %s", inputLabel, err).
WithParam(inputParam).
WithCause(err)
}
runtime.OutFormatRaw(docsScriptParseResult{Profile: docsScriptPublicProfile{
WordCount: profile.WordCount,
CharCount: profile.CharCount,
BlockCount: profile.BlockCount,
Blocks: profile.Blocks,
}}, nil, nil)
return nil
case docsScriptMarkdownToXML:
xml, err := docxparse.MarkdownToXML(content)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not convert --content from Markdown to LarkOpenCLI XML: %s", err).
WithParam("--content").
WithCause(err)
}
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
return saveDocsScriptXML(runtime, outputPath, xml)
}
runtime.OutFormatRaw(docsScriptMarkdownResult{XML: xml}, nil, nil)
return nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --command %q", command).
WithParam("--command")
}
}
func createDocsScriptTempXML(runtime *common.RuntimeContext) error {
creator, ok := runtime.FileIO().(fileio.TempDirFileCreator)
if !ok {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"the active file I/O provider does not support temporary file creation").
WithHint("run this command with the local file I/O provider")
}
fileName := strings.TrimSpace(runtime.Str("file-name"))
path, err := creator.CreateTempDirFile(docsScriptTempDirectoryPattern(fileName), docsScriptXMLFileName(fileName))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
if _, err := runtime.ResolveSavePath(path); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"resolve temporary XML path %s: %s", path, err).
WithCause(err)
}
runtime.Out(docsScriptTempXMLResult{
Path: path,
}, nil)
return nil
}
func validateDocsScriptTempXMLFileName(fileName string) error {
if fileName != filepath.Base(fileName) || strings.ContainsAny(fileName, "<>:\"/\\|?*\t\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name must be a portable file name without path separators or reserved characters").WithParam("--file-name")
}
if err := charcheck.RejectControlChars(fileName, "--file-name"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).
WithParam("--file-name").
WithCause(err)
}
if strings.HasSuffix(fileName, ".") || strings.HasSuffix(fileName, " ") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name must not end with a dot or space").WithParam("--file-name")
}
if strings.EqualFold(filepath.Ext(fileName), ".xml") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name must omit the .xml extension").WithParam("--file-name")
}
base := strings.ToUpper(strings.SplitN(fileName, ".", 2)[0])
if isWindowsReservedFileName(base) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--file-name uses a Windows-reserved device name").WithParam("--file-name")
}
return nil
}
func docsScriptTempDirectoryPattern(fileName string) string {
return fileName + docsScriptTempDirSuffix
}
func docsScriptXMLFileName(fileName string) string {
return fileName + ".xml"
}
func isWindowsReservedFileName(base string) bool {
switch base {
case "CON", "PRN", "AUX", "NUL":
return true
}
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
return base[3] >= '1' && base[3] <= '9'
}
return false
}
func docsScriptFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": "xml",
"extra_param": docsFetchExtraParam,
"export_option": map[string]interface{}{
"export_block_id": false,
"export_style_attrs": false,
"export_cite_extra_data": false,
},
}
if lang := resolveFetchLang(runtime); lang != "" {
body["lang"] = lang
}
return body
}
func fetchDocsScriptContent(runtime *common.RuntimeContext) (string, error) {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", ref.Token)
data, err := doDocAPI(runtime, "POST", apiPath, docsScriptFetchBody(runtime))
if err != nil {
return "", err
}
document, ok := data["document"].(map[string]interface{})
if !ok || document == nil {
return "", errs.NewInternalError(errs.SubtypeUnknown,
"document fetch response for --doc is missing document")
}
content, ok := document["content"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown,
"document fetch response for --doc is missing document.content")
}
return content, nil
}
func saveDocsScriptXML(runtime *common.RuntimeContext, outputPath, xml string) error {
if !runtime.Bool("overwrite") {
if _, err := runtime.FileIO().Stat(outputPath); err == nil {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"output file already exists: %s (use --overwrite to replace)", outputPath).
WithParam("--output")
} else if !errors.Is(err, fs.ErrNotExist) {
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
WithParam("--output").
WithCause(err)
}
return errs.NewInternalError(errs.SubtypeFileIO,
"cannot access output path %s: %s", outputPath, err).
WithCause(err)
}
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(xml)),
}, strings.NewReader(xml))
if err != nil {
return common.WrapSaveErrorTyped(err)
}
savedPath, err := runtime.ResolveSavePath(outputPath)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"resolve saved XML path %s: %s", outputPath, err).
WithCause(err)
}
runtime.Out(docsScriptMarkdownFileResult{
SavedPath: savedPath,
SizeBytes: result.Size(),
}, nil)
return nil
}

View File

@@ -1,660 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
)
func TestDocsScriptParsesAndProfilesXML(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test"))
source := `<title>标题</title><p>一个苹果是 an apple。</p>`
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", source,
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if !envelope.OK {
t.Fatalf("ok = false: %s", stdout)
}
if len(envelope.Data) != 1 || envelope.Data["profile"] == nil {
t.Fatalf("data = %+v, want only profile", envelope.Data)
}
var profile docsScriptPublicProfile
if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
t.Fatalf("decode profile: %v", err)
}
var profileFields map[string]json.RawMessage
if err := json.Unmarshal(envelope.Data["profile"], &profileFields); err != nil {
t.Fatalf("decode profile fields: %v", err)
}
if len(profileFields) != 4 || profileFields["breakdown"] != nil {
t.Fatalf("profile fields = %+v, want breakdown hidden", profileFields)
}
if profile.WordCount != 10 || profile.CharCount != 15 || profile.BlockCount != 2 {
t.Fatalf("profile = %+v", profile)
}
if got := blockCount(profile.Blocks, "title"); got != 1 {
t.Fatalf("title count = %d, want 1", got)
}
if got := blockCount(profile.Blocks, "p"); got != 1 {
t.Fatalf("p count = %d, want 1", got)
}
}
func TestDocsScriptParseAutoDetectsMarkdown(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-auto-markdown"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", "# 标题\n\n- item",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}
var envelope struct {
Data docsScriptParseResult `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if envelope.Data.Profile.BlockCount != 3 {
t.Fatalf("profile = %+v, want 3 blocks", envelope.Data.Profile)
}
}
func TestDocsScriptParsesOnlineDocumentFromToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-token"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnScriptToken/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnScriptToken",
"content": `<title>在线文档</title><p>Hello world</p>`,
},
})
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--doc", "doxcnScriptToken",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script with token: %v", err)
}
var envelope struct {
Data docsScriptParseResult `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if envelope.Data.Profile.BlockCount != 2 {
t.Fatalf("profile = %+v, want 2 blocks", envelope.Data.Profile)
}
if got := blockCount(envelope.Data.Profile.Blocks, "title"); got != 1 {
t.Fatalf("title count = %d, want 1", got)
}
if got := blockCount(envelope.Data.Profile.Blocks, "p"); got != 1 {
t.Fatalf("p count = %d, want 1", got)
}
}
func TestDocsScriptParsesOnlineDocumentFromURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-url"))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/wikcnScriptURL/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnResolvedScriptURL",
"content": `<p>从 Wiki URL 读取</p>`,
},
})
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--doc", "https://example.larksuite.com/wiki/wikcnScriptURL",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script with URL: %v", err)
}
if stub.CapturedBody == nil {
t.Fatal("online parse did not call the document fetch API")
}
var envelope struct {
Data docsScriptParseResult `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if envelope.Data.Profile.BlockCount != 1 || blockCount(envelope.Data.Profile.Blocks, "p") != 1 {
t.Fatalf("profile = %+v, want one paragraph", envelope.Data.Profile)
}
}
func TestDocsScriptRejectsContentAndDocTogether(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-input-conflict"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<p>local</p>`,
"--doc", "doxcnScriptConflict",
"--as", "bot",
}, f, nil)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--content", "--doc")
}
func TestDocsScriptRejectsDocForMarkdownConversion(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-doc-convert"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--doc", "doxcnScriptConvert",
"--as", "bot",
}, f, nil)
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--doc")
}
func TestDocsScriptConvertsMarkdownFromStdin(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-markdown"))
f.IOStreams.In = bytes.NewBufferString("# 标题\n\n- item")
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "-",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}
if !strings.Contains(stdout.String(), `<h1>标题</h1><ul><li>item</li></ul>`) {
t.Fatalf("stdout missing converted XML: %s", stdout)
}
var envelope struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if len(envelope.Data) != 1 || envelope.Data["xml"] == nil {
t.Fatalf("data = %+v, want only xml", envelope.Data)
}
}
func TestDocsScriptConvertsMarkdownToOutputFile(t *testing.T) {
workDir := t.TempDir()
withDocsWorkingDir(t, workDir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output"))
wantXML := `<h1>标题</h1><ul><li>item</li></ul>`
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "# 标题\n\n- item",
"--output", "draft.xml",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}
gotXML, err := os.ReadFile("draft.xml")
if err != nil {
t.Fatalf("read output XML: %v", err)
}
if string(gotXML) != wantXML {
t.Fatalf("output XML = %q, want %q", gotXML, wantXML)
}
var envelope struct {
Data struct {
SavedPath string `json:"saved_path"`
SizeBytes int64 `json:"size_bytes"`
XML json.RawMessage `json:"xml"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
if envelope.Data.SavedPath != filepath.Join(workDir, "draft.xml") {
t.Fatalf("saved_path = %q, want %q", envelope.Data.SavedPath, filepath.Join(workDir, "draft.xml"))
}
if envelope.Data.SizeBytes != int64(len(wantXML)) {
t.Fatalf("size_bytes = %d, want %d", envelope.Data.SizeBytes, len(wantXML))
}
if envelope.Data.XML != nil {
t.Fatalf("data.xml should be omitted when --output is used: %s", stdout)
}
}
func TestDocsScriptCreatesUniqueTempXMLFiles(t *testing.T) {
workDir := t.TempDir()
withDocsWorkingDir(t, workDir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml"))
create := func() docsScriptTempXMLResult {
t.Helper()
stdout.Reset()
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptCreateTempXML,
"--file-name", "川西",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script: %v", err)
}
var envelope struct {
Data docsScriptTempXMLResult `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout)
}
return envelope.Data
}
first := create()
second := create()
if first.Path == second.Path {
t.Fatalf("temporary paths are identical: %q", first.Path)
}
for _, got := range []docsScriptTempXMLResult{first, second} {
directory := filepath.Dir(got.Path)
if filepath.Base(got.Path) != "川西.xml" || filepath.Base(directory) != directory ||
!strings.HasPrefix(directory, "川西_") || !strings.HasSuffix(directory, "_folder") {
t.Fatalf("path = %q, want 川西_<random>_folder/川西.xml", got.Path)
}
info, err := os.Stat(got.Path)
if err != nil {
t.Fatalf("stat temporary XML %q: %v", got.Path, err)
}
if info.Size() != 0 {
t.Fatalf("temporary XML %q size = %d, want 0", got.Path, info.Size())
}
}
}
func TestDocsScriptCreateTempXMLRejectsOtherFlags(t *testing.T) {
tests := []struct {
name string
args []string
param string
}{
{name: "content", args: []string{"--content", "<p>text</p>"}, param: "--content"},
{name: "doc", args: []string{"--doc", "doxcnScriptTemp"}, param: "--doc"},
{name: "output", args: []string{"--output", "draft.xml"}, param: "--output"},
{name: "overwrite", args: []string{"--overwrite"}, param: "--overwrite"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-flags"))
args := []string{"+script", "--command", docsScriptCreateTempXML, "--file-name", "川西", "--as", "bot"}
args = append(args, test.args...)
err := mountAndRunDocs(t, DocsScript, args, f, nil)
if err == nil {
t.Fatalf("expected %s validation error", test.param)
}
problem, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
!errors.As(err, &validationErr) || validationErr.Param != test.param {
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
}
})
}
}
func TestDocsScriptCreateTempXMLValidatesFileName(t *testing.T) {
tests := []struct {
name string
fileName string
}{
{name: "missing"},
{name: "path", fileName: "folder/川西"},
{name: "windows path", fileName: `folder\川西`},
{name: "reserved character", fileName: "川西:一"},
{name: "xml extension included", fileName: "川西.xml"},
{name: "windows device", fileName: "CON"},
{name: "surrounding whitespace", fileName: " 川西"},
{name: "dangerous unicode", fileName: "川\u200b西"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-file-name"))
args := []string{"+script", "--command", docsScriptCreateTempXML, "--as", "bot"}
if test.fileName != "" {
args = append(args, "--file-name", test.fileName)
}
err := mountAndRunDocs(t, DocsScript, args, f, nil)
if err == nil {
t.Fatalf("expected --file-name validation error for %q", test.fileName)
}
problem, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
!errors.As(err, &validationErr) || validationErr.Param != "--file-name" {
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
}
})
}
}
func TestDocsScriptOutputRequiresExplicitOverwrite(t *testing.T) {
withDocsWorkingDir(t, t.TempDir())
if err := os.WriteFile("draft.xml", []byte("old"), 0o600); err != nil {
t.Fatalf("write existing output: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-overwrite"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "# new",
"--output", "draft.xml",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected existing output error")
}
problem, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition ||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
}
got, readErr := os.ReadFile("draft.xml")
if readErr != nil || string(got) != "old" {
t.Fatalf("existing output changed: content=%q err=%v", got, readErr)
}
err = mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "# new",
"--output", "draft.xml",
"--overwrite",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script with --overwrite: %v", err)
}
got, readErr = os.ReadFile("draft.xml")
if readErr != nil || string(got) != "<h1>new</h1>" {
t.Fatalf("overwritten output = %q, err=%v", got, readErr)
}
}
func TestDocsScriptRejectsOutputForParse(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-parse"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<p>text</p>`,
"--output", "draft.xml",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected --output validation error")
}
problem, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
}
}
func TestDocsScriptRejectsUnsafeOutputPath(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-path"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "# title",
"--output", filepath.Join(t.TempDir(), "draft.xml"),
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected unsafe output path error")
}
problem, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
!errors.As(err, &validationErr) || validationErr.Param != "--output" {
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
}
}
func TestDocsScriptDryRunHasNoAPICall(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<p>text</p>`,
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script dry-run: %v", err)
}
var got struct {
API []any `json:"api"`
Command string `json:"command"`
Network bool `json:"network"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
}
if len(got.API) != 0 || got.Command != docsScriptParse || got.Network {
t.Fatalf("dry-run output = %+v", got)
}
}
func TestDocsScriptCreateTempXMLDryRunDoesNotWrite(t *testing.T) {
workDir := t.TempDir()
withDocsWorkingDir(t, workDir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-temp-xml-dry-run"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptCreateTempXML,
"--file-name", "川西",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script dry-run: %v", err)
}
var got struct {
API []any `json:"api"`
Command string `json:"command"`
DirectoryPattern string `json:"directory_pattern"`
FileName string `json:"file_name"`
XMLFileName string `json:"xml_file_name"`
CreatesFile bool `json:"creates_file"`
Network bool `json:"network"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
}
if len(got.API) != 0 || got.Command != docsScriptCreateTempXML ||
got.DirectoryPattern != "川西_*_folder" || got.FileName != "川西" || got.XMLFileName != "川西.xml" ||
got.CreatesFile || got.Network {
t.Fatalf("dry-run output = %+v", got)
}
entries, err := os.ReadDir(workDir)
if err != nil {
t.Fatalf("read work directory: %v", err)
}
if len(entries) != 0 {
t.Fatalf("dry-run created files: %+v", entries)
}
}
func TestDocsScriptOnlineDryRunShowsFetchAPICall(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-dry-run"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--doc", "https://example.larksuite.com/docx/doxcnScriptDryRun",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute online docs +script dry-run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
Command string `json:"command"`
DocumentID string `json:"document_id"`
Network bool `json:"network"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
}
if len(got.API) != 1 || got.API[0].Method != "POST" ||
got.API[0].URL != "/open-apis/docs_ai/v1/documents/doxcnScriptDryRun/fetch" {
t.Fatalf("dry-run API = %+v", got.API)
}
if got.API[0].Body["format"] != "xml" {
t.Fatalf("dry-run body = %+v, want XML fetch", got.API[0].Body)
}
if got.Command != docsScriptParse || got.DocumentID != "doxcnScriptDryRun" || !got.Network {
t.Fatalf("dry-run output = %+v", got)
}
}
func TestDocsScriptOutputDryRunDoesNotWrite(t *testing.T) {
withDocsWorkingDir(t, t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-output-dry-run"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptMarkdownToXML,
"--content", "# title",
"--output", "draft.xml",
"--overwrite",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("execute docs +script dry-run: %v", err)
}
var got struct {
API []any `json:"api"`
Command string `json:"command"`
Network bool `json:"network"`
Output string `json:"output"`
Overwrite bool `json:"overwrite"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run stdout: %v\n%s", err, stdout)
}
if len(got.API) != 0 || got.Command != docsScriptMarkdownToXML || got.Network || got.Output != "draft.xml" || !got.Overwrite {
t.Fatalf("dry-run output = %+v", got)
}
if _, err := os.Stat("draft.xml"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("dry-run created output file: %v", err)
}
}
func TestDocsScriptReturnsTypedParseError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<!DOCTYPE document><p>text</p>`,
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected parse error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, ok=%v", problem, ok)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--content" {
t.Fatalf("error = %#v, want --content metadata", err)
}
}
func TestDocsScriptRejectsMalformedXML(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed"))
err := mountAndRunDocs(t, DocsScript, []string{
"+script",
"--command", docsScriptParse,
"--content", `<p>text`,
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected malformed XML error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, ok=%v", problem, ok)
}
}
func TestDocsScriptHelpExamplesAreCrossShellSafe(t *testing.T) {
cmd := &cobra.Command{Short: "local document parser"}
installDocsScriptHelp(cmd)
if strings.Contains(cmd.Example, "cat ") {
t.Fatalf("help examples require a platform-specific command: %q", cmd.Example)
}
if strings.Contains(cmd.Example, "--content @") {
t.Fatalf("help examples contain an unquoted @file argument: %q", cmd.Example)
}
for _, want := range []string{`--command create-temp-xml --file-name "draft"`, `--content "@draft.xml"`, `--content "@draft.md"`, `--output "draft.xml"`} {
if !strings.Contains(cmd.Example, want) {
t.Errorf("help examples missing %q: %q", want, cmd.Example)
}
}
}
func blockCount(blocks []docxparse.BlockShare, typ string) int {
for _, block := range blocks {
if block.Type == typ {
return block.Count
}
}
return 0
}

View File

@@ -1,743 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
// Markdown conversion is scoped to the docs +script business domain.
import (
"fmt"
"strings"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
gmutil "github.com/yuin/goldmark/util"
)
var markdownParser parser.Parser
func init() {
markdown := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.DefinitionList,
&mathExtension{},
&underscoreHTMLExtension{},
),
goldmark.WithParserOptions(
parser.WithBlockParsers(gmutil.Prioritized(&containerBlockParser{}, 90)),
),
)
markdownParser = markdown.Parser()
}
func parseMarkdown(source string) ([]*Node, error) {
if err := validateSource(source); err != nil {
return nil, err
}
source = strings.TrimPrefix(source, "\uFEFF")
source = normalizeListIndent(source)
source = preprocessCJKAdjacentMarkup(source)
data := []byte(source)
document := markdownParser.Parse(text.NewReader(data))
return renderBlockChildren(document, data)
}
func renderBlockChildren(parent gast.Node, source []byte) ([]*Node, error) {
var out []*Node
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
nodes, err := renderBlockNode(child, source)
if err != nil {
return nil, err
}
out = append(out, nodes...)
}
return out, nil
}
func renderBlockNode(node gast.Node, source []byte) ([]*Node, error) {
switch node.Kind() {
case gast.KindParagraph, gast.KindTextBlock:
children, err := renderInlineChildren(node, source)
if err != nil {
return nil, err
}
return wrapParagraphChildren(children), nil
case gast.KindHeading:
heading := newElement(headingTag(node.(*gast.Heading).Level), nil)
children, err := renderInlineChildren(node, source)
if err != nil {
return nil, err
}
for _, child := range children {
heading.addChild(child)
}
return []*Node{heading}, nil
case gast.KindBlockquote:
return renderContainer("blockquote", nil, node, source)
case gast.KindList:
return renderList(node.(*gast.List), source)
case gast.KindFencedCodeBlock:
block := node.(*gast.FencedCodeBlock)
language := string(block.Language(source))
content := trimOneTrailingNewline(string(node.Lines().Value(source)))
lowerLanguage := strings.ToLower(language)
if content != "" && (lowerLanguage == "mermaid" || lowerLanguage == "plantuml" || lowerLanguage == "svg") {
whiteboard := newElement("whiteboard", map[string]string{"type": lowerLanguage})
appendRawTextWithBreaks(whiteboard, content)
return []*Node{whiteboard}, nil
}
attrs := map[string]string(nil)
if language != "" {
attrs = map[string]string{"lang": language}
}
pre := newElement("pre", attrs)
code := newElement("code", nil)
appendRawTextWithBreaks(code, content)
pre.addChild(code)
return []*Node{pre}, nil
case gast.KindCodeBlock:
pre := newElement("pre", nil)
code := newElement("code", nil)
appendRawTextWithBreaks(code, trimOneTrailingNewline(string(node.Lines().Value(source))))
pre.addChild(code)
return []*Node{pre}, nil
case gast.KindThematicBreak:
return []*Node{newElement("hr", nil)}, nil
case gast.KindHTMLBlock:
nodes, err := parseMarkdownHTMLBlock(string(node.Lines().Value(source)))
if err != nil {
return nil, err
}
stripMarkdownEscapesInNodes(nodes, false, false)
return nodes, nil
case kindContainerBlock:
container := node.(*containerBlock)
return renderContainer(container.spec.tag, container.attrs, node, source)
}
switch node.Kind() {
case extast.KindTable:
return renderTable(node, source)
case extast.KindDefinitionList:
return renderDefinitionList(node, source)
}
value := strings.TrimSpace(extractMarkdownText(node, source))
if value == "" {
return nil, nil
}
paragraph := newElement("p", nil)
paragraph.addChild(newText(value))
return []*Node{paragraph}, nil
}
// parseMarkdownHTMLBlock handles the source-bearing LarkOpenCLI blocks whose
// Markdown bodies are literal text, then delegates every other XML fragment to
// the strict XML parser. Escaping literal code is part of Markdown conversion.
func parseMarkdownHTMLBlock(fragment string) ([]*Node, error) {
trimmed := strings.TrimSpace(fragment)
for _, tag := range []string{"code", "whiteboard"} {
closing := "</" + tag + ">"
if !strings.HasPrefix(trimmed, "<"+tag) || !strings.HasSuffix(trimmed, closing) {
continue
}
token, contentStart, state := scanXMLToken(trimmed, 0)
if state != tokenOK || token.closing || token.selfClosing || token.name != tag {
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
}
contentEnd := len(trimmed) - len(closing)
if contentStart > contentEnd {
return nil, fmt.Errorf("invalid Markdown <%s> block", tag)
}
attrs := normalizeAttributes(tag, tag, token.attrs)
block := newElement(tag, attrs)
appendRawTextWithBreaks(block, strings.Trim(trimmed[contentStart:contentEnd], "\r\n"))
return []*Node{block}, nil
}
return parseXML(fragment)
}
func renderContainer(tag string, attrs map[string]string, node gast.Node, source []byte) ([]*Node, error) {
attrs = normalizeAttributes(tag, tag, attrs)
container := newElement(tag, attrs)
children, err := renderBlockChildren(node, source)
if err != nil {
return nil, err
}
for _, child := range children {
container.addChild(child)
}
return []*Node{container}, nil
}
func renderList(list *gast.List, source []byte) ([]*Node, error) {
if isTaskList(list) {
return renderTaskList(list, source)
}
tag := "ul"
if list.IsOrdered() {
tag = "ol"
}
listNode := newElement(tag, nil)
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() != gast.KindListItem {
continue
}
item, err := renderListItem(child.(*gast.ListItem), list.IsTight, source)
if err != nil {
return nil, err
}
listNode.addChild(item)
}
return []*Node{listNode}, nil
}
func isTaskList(list *gast.List) bool {
first := list.FirstChild()
if first == nil || first.Kind() != gast.KindListItem {
return false
}
return findTaskCheckbox(first.(*gast.ListItem)) != nil
}
func findTaskCheckbox(item *gast.ListItem) *extast.TaskCheckBox {
for child := item.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() != gast.KindTextBlock && child.Kind() != gast.KindParagraph {
continue
}
if first := child.FirstChild(); first != nil && first.Kind() == extast.KindTaskCheckBox {
return first.(*extast.TaskCheckBox)
}
}
return nil
}
func renderTaskList(list *gast.List, source []byte) ([]*Node, error) {
var out []*Node
for child := list.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() != gast.KindListItem {
continue
}
item := child.(*gast.ListItem)
checkboxAST := findTaskCheckbox(item)
if checkboxAST == nil {
li, err := renderListItem(item, list.IsTight, source)
if err != nil {
return nil, err
}
ul := newElement("ul", nil)
ul.addChild(li)
out = append(out, ul)
continue
}
done := "false"
if checkboxAST.IsChecked {
done = "true"
}
checkbox := newElement("checkbox", map[string]string{"done": done})
for block := item.FirstChild(); block != nil; block = block.NextSibling() {
if block.Kind() == gast.KindTextBlock || block.Kind() == gast.KindParagraph {
fragment, err := renderInlineFragment(block, source, true)
if err != nil {
return nil, err
}
nodes, err := parseMarkdownInlineFragment(fragment)
if err != nil {
return nil, err
}
for _, node := range nodes {
checkbox.addChild(node)
}
continue
}
nodes, err := renderBlockNode(block, source)
if err != nil {
return nil, err
}
for _, node := range nodes {
checkbox.addChild(node)
}
}
out = append(out, checkbox)
}
return out, nil
}
func renderListItem(item *gast.ListItem, tight bool, source []byte) (*Node, error) {
li := newElement("li", nil)
children, err := renderBlockChildren(item, source)
if err != nil {
return nil, err
}
for _, child := range children {
if child.tag == "p" && (tight || paragraphOnlyInline(child)) {
for _, grandchild := range child.children {
li.addChild(grandchild)
}
continue
}
li.addChild(child)
}
return li, nil
}
func renderInlineChildren(node gast.Node, source []byte) ([]*Node, error) {
fragment, err := renderInlineFragment(node, source, false)
if err != nil {
return nil, err
}
nodes, err := parseMarkdownInlineFragment(fragment)
if err != nil {
return nil, err
}
stripMarkdownEscapesInNodes(nodes, false, false)
return nodes, nil
}
// parseMarkdownInlineFragment wraps an inline fragment in a space-preserving
// parent while parsing so XML normalization keeps semantic spaces between
// adjacent inline elements. The wrapper is removed from the returned nodes.
func parseMarkdownInlineFragment(fragment string) ([]*Node, error) {
nodes, err := parseXML("<p>" + fragment + "</p>")
if err != nil {
return nil, err
}
if len(nodes) != 1 || nodes[0].typ != nodeElement || nodes[0].tag != "p" {
return nil, fmt.Errorf("invalid Markdown inline fragment")
}
children := nodes[0].children
for _, child := range children {
child.parent = nil
}
return children, nil
}
func renderInlineFragment(parent gast.Node, source []byte, skipCheckbox bool) (string, error) {
var out strings.Builder
for child := parent.FirstChild(); child != nil; child = child.NextSibling() {
if skipCheckbox && child.Kind() == extast.KindTaskCheckBox {
continue
}
fragment, err := renderInlineNode(child, source)
if err != nil {
return "", err
}
out.WriteString(fragment)
}
return out.String(), nil
}
func renderInlineNode(node gast.Node, source []byte) (string, error) {
switch node.Kind() {
case gast.KindText:
textNode := node.(*gast.Text)
value := escapeXMLText(stripBackslashEscapes(string(textNode.Value(source))))
switch {
case textNode.HardLineBreak():
value += "<br/>"
case textNode.SoftLineBreak():
value += " "
}
return value, nil
case gast.KindString:
return escapeXMLText(string(node.(*gast.String).Value)), nil
case gast.KindEmphasis:
tag := "em"
if node.(*gast.Emphasis).Level >= 2 {
tag = "b"
}
return renderInlineContainer(node, tag, nil, source)
case gast.KindCodeSpan:
return elementXML("code", nil, escapeXMLText(collectMarkdownChildText(node, source))), nil
case gast.KindLink:
link := node.(*gast.Link)
attrs := map[string]string{"href": string(link.Destination)}
if len(link.Title) > 0 {
attrs["title"] = string(link.Title)
}
children, err := renderInlineFragment(node, source, false)
if err != nil {
return "", err
}
if children == "" {
children = escapeXMLText(string(link.Destination))
}
return elementXML("a", attrs, children), nil
case gast.KindImage:
image := node.(*gast.Image)
destination := string(image.Destination)
attrs := map[string]string{}
if strings.HasPrefix(destination, "http://") || strings.HasPrefix(destination, "https://") {
attrs["href"] = destination
} else {
attrs["src"] = destination
}
if len(image.Title) > 0 {
attrs["title"] = string(image.Title)
}
return elementXML("img", attrs, ""), nil
case gast.KindRawHTML:
return string(node.(*gast.RawHTML).Segments.Value(source)), nil
case gast.KindAutoLink:
link := node.(*gast.AutoLink)
return elementXML("a", map[string]string{"href": string(link.URL(source))}, escapeXMLText(string(link.Label(source)))), nil
}
switch node.Kind() {
case extast.KindStrikethrough:
return renderInlineContainer(node, "del", nil, source)
case kindMathInline:
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathInline).content)))), nil
case kindMathBlock:
return elementXML("latex", nil, escapeXMLText(stripLatexMarkdownEscapes(string(node.(*mathBlock).content)))), nil
case extast.KindTaskCheckBox:
return "", nil
}
if node.Type() == gast.TypeBlock {
return escapeXMLText(strings.TrimSpace(extractMarkdownText(node, source))), nil
}
return escapeXMLText(extractMarkdownText(node, source)), nil
}
func renderInlineContainer(node gast.Node, tag string, attrs map[string]string, source []byte) (string, error) {
children, err := renderInlineFragment(node, source, false)
if err != nil {
return "", err
}
return elementXML(tag, attrs, children), nil
}
func elementXML(tag string, attrs map[string]string, inner string) string {
node := newElement(tag, attrs)
rendered := renderNodes([]*Node{node})
if inner == "" {
return rendered
}
close := "</" + tag + ">"
if strings.HasSuffix(rendered, close) {
return strings.TrimSuffix(rendered, close) + inner + close
}
return rendered
}
func wrapParagraphChildren(children []*Node) []*Node {
var out []*Node
var inline []*Node
flush := func() {
if len(inline) == 0 {
return
}
paragraph := newElement("p", nil)
for _, child := range inline {
paragraph.addChild(child)
}
out = append(out, paragraph)
inline = nil
}
for _, child := range children {
if child != nil && child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
flush()
out = append(out, child)
continue
}
inline = append(inline, child)
}
flush()
return out
}
func paragraphOnlyInline(node *Node) bool {
if node == nil || node.typ != nodeElement || node.tag != "p" {
return false
}
for _, child := range node.children {
if child.typ == nodeElement && layoutOf(child.tag) == layoutBlock {
return false
}
}
return true
}
func renderTable(node gast.Node, source []byte) ([]*Node, error) {
table := newElement("table", nil)
var body *Node
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
switch child.Kind() {
case extast.KindTableHeader:
head := newElement("thead", nil)
row, err := renderTableRow(child, true, source)
if err != nil {
return nil, err
}
head.addChild(row)
table.addChild(head)
case extast.KindTableRow:
if body == nil {
body = newElement("tbody", nil)
table.addChild(body)
}
row, err := renderTableRow(child, false, source)
if err != nil {
return nil, err
}
body.addChild(row)
}
}
return []*Node{table}, nil
}
func renderTableRow(node gast.Node, header bool, source []byte) (*Node, error) {
row := newElement("tr", nil)
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
if child.Kind() != extast.KindTableCell {
continue
}
cellAST := child.(*extast.TableCell)
tag := "td"
if header {
tag = "th"
}
attrs := map[string]string(nil)
switch cellAST.Alignment {
case extast.AlignCenter:
attrs = map[string]string{"align": "center"}
case extast.AlignRight:
attrs = map[string]string{"align": "right"}
}
cell := newElement(tag, attrs)
content, err := renderInlineChildren(cellAST, source)
if err != nil {
return nil, err
}
for _, inline := range content {
cell.addChild(inline)
}
row.addChild(cell)
}
return row, nil
}
func renderDefinitionList(node gast.Node, source []byte) ([]*Node, error) {
var out []*Node
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
switch child.Kind() {
case extast.KindDefinitionTerm:
fragment, err := renderInlineFragment(child, source, false)
if err != nil {
return nil, err
}
nodes, err := parseXML(fragment)
if err != nil {
return nil, err
}
paragraph := newElement("p", nil)
bold := newElement("b", nil)
for _, node := range nodes {
bold.addChild(node)
}
paragraph.addChild(bold)
out = append(out, paragraph)
case extast.KindDefinitionDescription:
quote, err := renderContainer("blockquote", nil, child, source)
if err != nil {
return nil, err
}
out = append(out, quote...)
}
}
return out, nil
}
func appendRawTextWithBreaks(parent *Node, content string) {
if content == "" {
return
}
start := 0
for i := 0; i < len(content); i++ {
if content[i] != '\n' && content[i] != '\r' {
continue
}
if i > start {
parent.addChild(newText(content[start:i]))
}
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
i++
}
parent.addChild(newElement("br", nil))
start = i + 1
}
if start < len(content) {
parent.addChild(newText(content[start:]))
}
}
func stripMarkdownEscapesInNodes(nodes []*Node, inCode, inLatex bool) {
for _, node := range nodes {
if node == nil {
continue
}
if node.typ == nodeText {
switch {
case inCode:
case inLatex:
node.text = stripLatexMarkdownEscapes(node.text)
default:
node.text = stripBackslashEscapes(node.text)
}
continue
}
stripMarkdownEscapesInNodes(node.children, inCode || node.tag == "code" || node.tag == "pre", inLatex || node.tag == "latex")
}
}
func stripBackslashEscapes(value string) string {
if !strings.Contains(value, `\`) {
return value
}
var out strings.Builder
out.Grow(len(value))
for i := 0; i < len(value); i++ {
if value[i] == '\\' && i+1 < len(value) && isASCIIPunctuation(value[i+1]) {
out.WriteByte(value[i+1])
i++
continue
}
out.WriteByte(value[i])
}
return out.String()
}
func stripLatexMarkdownEscapes(value string) string {
if !strings.Contains(value, `\`) {
return value
}
var out strings.Builder
out.Grow(len(value))
for i := 0; i < len(value); i++ {
if value[i] == '\\' && i+1 < len(value) && strings.ContainsRune("_^&*[]$~<>`#+-=:", rune(value[i+1])) {
out.WriteByte(value[i+1])
i++
continue
}
out.WriteByte(value[i])
}
return out.String()
}
func isASCIIPunctuation(ch byte) bool {
return ch >= '!' && ch <= '/' || ch >= ':' && ch <= '@' || ch >= '[' && ch <= '`' || ch >= '{' && ch <= '~'
}
func trimOneTrailingNewline(value string) string {
if strings.HasSuffix(value, "\r\n") {
return value[:len(value)-2]
}
return strings.TrimSuffix(value, "\n")
}
func collectMarkdownChildText(node gast.Node, source []byte) string {
var out strings.Builder
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
switch child.Kind() {
case gast.KindText:
out.Write(child.(*gast.Text).Value(source))
case gast.KindString:
out.Write(child.(*gast.String).Value)
default:
out.WriteString(collectMarkdownChildText(child, source))
}
}
return out.String()
}
func extractMarkdownText(node gast.Node, source []byte) string {
switch node.Kind() {
case gast.KindText:
return string(node.(*gast.Text).Value(source))
case gast.KindString:
return string(node.(*gast.String).Value)
case gast.KindCodeSpan:
return collectMarkdownChildText(node, source)
}
if node.Type() == gast.TypeBlock && node.Lines() != nil && node.Lines().Len() > 0 {
return string(node.Lines().Value(source))
}
var out strings.Builder
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
out.WriteString(extractMarkdownText(child, source))
}
return out.String()
}
func headingTag(level int) string {
if level < 1 || level > 6 {
return "p"
}
return fmt.Sprintf("h%d", level)
}
func normalizeListIndent(markdown string) string {
lines := strings.Split(markdown, "\n")
type stackEntry struct{ indent int }
var stack []stackEntry
inFence := false
changed := false
lastOriginal, lastNormalized := 0, 0
for i, line := range lines {
trimmed := strings.TrimLeft(line, " ")
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
inFence = !inFence
continue
}
if inFence || trimmed == "" {
continue
}
indent := len(line) - len(trimmed)
if markdownListMarkerLength(trimmed) > 0 {
for len(stack) > 0 && indent <= stack[len(stack)-1].indent {
stack = stack[:len(stack)-1]
}
normalized := len(stack) * 4
stack = append(stack, stackEntry{indent: indent})
lastOriginal, lastNormalized = indent, normalized
if indent != normalized {
lines[i] = strings.Repeat(" ", normalized) + trimmed
changed = true
}
} else if len(stack) > 0 && indent > lastOriginal {
delta := lastNormalized - lastOriginal
if delta != 0 {
normalized := indent + delta
if normalized < 0 {
normalized = 0
}
lines[i] = strings.Repeat(" ", normalized) + trimmed
changed = true
}
}
}
if !changed {
return markdown
}
return strings.Join(lines, "\n")
}
func markdownListMarkerLength(value string) int {
if len(value) >= 2 && (value[0] == '-' || value[0] == '*' || value[0] == '+') && value[1] == ' ' {
return 2
}
i := 0
for i < len(value) && value[i] >= '0' && value[i] <= '9' {
i++
}
if i > 0 && i+1 < len(value) && (value[i] == '.' || value[i] == ')') && value[i+1] == ' ' {
return i + 2
}
return 0
}

View File

@@ -1,284 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
import (
"strings"
"unicode"
)
// preprocessCJKAdjacentMarkup disambiguates a narrow CommonMark pattern common in
// Chinese prose: emphasis that ends in punctuation and is immediately followed
// by a letter (for example **结论。**下一步). Goldmark correctly follows
// CommonMark's delimiter rules, while LarkOpenCLI accepts this authoring form.
// Rewriting simple CJK delimiter spans to equivalent DocxXML
// before parsing removes the ambiguity while leaving nested Markdown, links,
// code, fenced blocks, and source-bearing XML untouched.
func preprocessCJKAdjacentMarkup(markdown string) string {
if !strings.Contains(markdown, "**") && !strings.Contains(markdown, "~~") {
return markdown
}
lines := strings.SplitAfter(markdown, "\n")
var out strings.Builder
fenceMarker := rune(0)
fenceLength := 0
rawSourceTag := ""
for _, line := range lines {
trimmed := strings.TrimLeft(line, " \t>")
if marker, length, ok := markdownFence(trimmed); ok {
if fenceMarker == 0 {
fenceMarker, fenceLength = marker, length
} else if marker == fenceMarker && length >= fenceLength && strings.TrimSpace(runeTail(trimmed, length)) == "" {
fenceMarker, fenceLength = 0, 0
}
out.WriteString(line)
continue
}
if fenceMarker != 0 || leadingIndent(line) >= 4 {
out.WriteString(line)
continue
}
out.WriteString(rewriteCJKMarkupLine(line, &rawSourceTag))
}
return out.String()
}
func markdownFence(line string) (rune, int, bool) {
runes := []rune(line)
if len(runes) < 3 || runes[0] != '`' && runes[0] != '~' {
return 0, 0, false
}
marker := runes[0]
length := 0
for length < len(runes) && runes[length] == marker {
length++
}
return marker, length, length >= 3
}
func runeTail(value string, start int) string {
runes := []rune(value)
if start >= len(runes) {
return ""
}
return string(runes[start:])
}
func leadingIndent(line string) int {
count := 0
for _, r := range line {
switch r {
case ' ':
count++
case '\t':
count += 4
default:
return count
}
}
return count
}
type cjkMarkupRule struct {
delimiter []rune
openXML string
closeXML string
}
var cjkMarkupRules = []cjkMarkupRule{
{delimiter: []rune("***"), openXML: "<em><b>", closeXML: "</b></em>"},
{delimiter: []rune("~~"), openXML: "<del>", closeXML: "</del>"},
{delimiter: []rune("**"), openXML: "<b>", closeXML: "</b>"},
}
func rewriteCJKMarkupLine(line string, rawSourceTag *string) string {
if *rawSourceTag != "" {
runes := []rune(line)
closeTag := []rune("</" + *rawSourceTag + ">")
closeAt := indexRunesFold(runes, 0, closeTag)
if closeAt < 0 {
return line
}
closeEnd := closeAt + len(closeTag)
prefix := string(runes[:closeEnd])
*rawSourceTag = ""
return prefix + rewriteCJKMarkupLine(string(runes[closeEnd:]), rawSourceTag)
}
runes := []rune(line)
var out strings.Builder
for i := 0; i < len(runes); {
if runes[i] == '`' && !runeEscaped(runes, i) {
if end := codeSpanEnd(runes, i); end > i {
out.WriteString(string(runes[i:end]))
i = end
continue
}
}
if runes[i] == '<' {
if tag, end, selfClosing, ok := rawTagAt(runes, i); ok {
out.WriteString(string(runes[i:end]))
i = end
if !selfClosing && (tag == "code" || tag == "pre" || tag == "whiteboard") {
close := []rune("</" + tag + ">")
if closeAt := indexRunesFold(runes, i, close); closeAt >= 0 {
closeEnd := closeAt + len(close)
out.WriteString(string(runes[i:closeEnd]))
i = closeEnd
} else {
out.WriteString(string(runes[i:]))
*rawSourceTag = tag
return out.String()
}
}
continue
}
}
rewritten := false
for _, rule := range cjkMarkupRules {
if !exactDelimiterAt(runes, i, rule.delimiter) || runeEscaped(runes, i) {
continue
}
closeAt := delimiterCloser(runes, i+len(rule.delimiter), rule.delimiter)
if closeAt < 0 {
continue
}
content := runes[i+len(rule.delimiter) : closeAt]
if !shouldRewriteCJKMarkup(content) {
continue
}
out.WriteString(rule.openXML)
out.WriteString(escapeXMLText(stripBackslashEscapes(string(content))))
out.WriteString(rule.closeXML)
i = closeAt + len(rule.delimiter)
rewritten = true
break
}
if rewritten {
continue
}
out.WriteRune(runes[i])
i++
}
return out.String()
}
func rawTagAt(runes []rune, start int) (tag string, end int, selfClosing, ok bool) {
if start+1 >= len(runes) || !isASCIILetterRune(runes[start+1]) {
return "", 0, false, false
}
i := start + 1
for i < len(runes) && (isASCIILetterRune(runes[i]) || isASCIIDigitRune(runes[i]) || runes[i] == '-' || runes[i] == '_') {
i++
}
tag = strings.ToLower(string(runes[start+1 : i]))
quote := rune(0)
for ; i < len(runes); i++ {
if runes[i] == '\'' || runes[i] == '"' {
if quote == 0 {
quote = runes[i]
} else if quote == runes[i] {
quote = 0
}
continue
}
if runes[i] == '>' && quote == 0 {
trimmed := strings.TrimSpace(string(runes[start : i+1]))
return tag, i + 1, strings.HasSuffix(trimmed, "/>"), true
}
}
return "", 0, false, false
}
func indexRunesFold(haystack []rune, start int, needle []rune) int {
for i := start; i+len(needle) <= len(haystack); i++ {
if strings.EqualFold(string(haystack[i:i+len(needle)]), string(needle)) {
return i
}
}
return -1
}
func codeSpanEnd(runes []rune, open int) int {
length := 0
for open+length < len(runes) && runes[open+length] == '`' {
length++
}
for i := open + length; i < len(runes); i++ {
if runes[i] != '`' || runeEscaped(runes, i) {
continue
}
end := i
for end < len(runes) && runes[end] == '`' {
end++
}
if end-i == length {
return end
}
i = end - 1
}
return -1
}
func exactDelimiterAt(runes []rune, start int, delimiter []rune) bool {
if start+len(delimiter) > len(runes) {
return false
}
for i, want := range delimiter {
if runes[start+i] != want {
return false
}
}
marker := delimiter[0]
return (start == 0 || runes[start-1] != marker) && (start+len(delimiter) == len(runes) || runes[start+len(delimiter)] != marker)
}
func delimiterCloser(runes []rune, start int, delimiter []rune) int {
for i := start; i+len(delimiter) <= len(runes); i++ {
if runes[i] == '\n' {
return -1
}
if exactDelimiterAt(runes, i, delimiter) && !runeEscaped(runes, i) {
return i
}
}
return -1
}
func shouldRewriteCJKMarkup(content []rune) bool {
if len(content) == 0 || unicode.IsSpace(content[0]) || unicode.IsSpace(content[len(content)-1]) {
return false
}
for _, r := range content {
if r == '`' || r == '[' || r == ']' || r == '<' || r == '>' {
return false
}
}
if !containsCJK(content) {
return false
}
return true
}
func containsCJK(value []rune) bool {
for _, r := range value {
if isCJKRune(r) || r > unicode.MaxASCII && (unicode.IsPunct(r) || unicode.IsSymbol(r)) {
return true
}
}
return false
}
func isCJKRune(r rune) bool {
return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul)
}
func runeEscaped(runes []rune, index int) bool {
count := 0
for i := index - 1; i >= 0 && runes[i] == '\\'; i-- {
count++
}
return count%2 == 1
}

View File

@@ -1,334 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
// This file contains the small Goldmark extensions needed to match the
// LarkOpenCLI's Markdown surface: math, DocxXML tag names containing
// underscores, and Markdown-aware callout/grid/column containers.
import (
"bytes"
"regexp"
"strings"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
gmutil "github.com/yuin/goldmark/util"
)
// ---------- Math ----------
var kindMathInline = gast.NewNodeKind("DocxMathInline")
var kindMathBlock = gast.NewNodeKind("DocxMathBlock")
type mathInline struct {
gast.BaseInline
content []byte
}
func (n *mathInline) Kind() gast.NodeKind { return kindMathInline }
func (n *mathInline) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, nil, nil)
}
type mathBlock struct {
gast.BaseInline
content []byte
}
func (n *mathBlock) Kind() gast.NodeKind { return kindMathBlock }
func (n *mathBlock) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, nil, nil)
}
var (
mathBlockMultiLine = regexp.MustCompile(`(?s)^\$\$(.+?)\$\$`)
mathInlineMultiLine = regexp.MustCompile(`(?s)^\$([^ \t$].*?)\$`)
)
type mathInlineParser struct{}
func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} }
func (p *mathInlineParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
line, _ := reader.PeekLine()
if len(line) == 0 || line[0] != '$' {
return nil
}
if len(line) >= 2 && line[1] == '$' {
if content, advance := scanMathClose(line[2:], "$$"); advance >= 0 && len(content) > 0 {
reader.Advance(2 + advance)
return &mathBlock{content: append([]byte(nil), content...)}
}
match := reader.FindSubMatch(mathBlockMultiLine)
if len(match) >= 2 && len(bytes.TrimSpace(match[1])) > 0 && !bytes.Contains(match[1], []byte("<latex")) {
return &mathBlock{content: append([]byte(nil), bytes.TrimSpace(match[1])...)}
}
return nil
}
if len(line) < 2 || line[1] == ' ' || line[1] == '\t' || line[1] == '$' {
return nil
}
if content, advance := scanMathClose(line[1:], "$"); advance >= 0 && len(content) > 0 {
if content[len(content)-1] == ' ' || content[len(content)-1] == '\t' {
return nil
}
reader.Advance(1 + advance)
return &mathInline{content: append([]byte(nil), content...)}
}
match := reader.FindSubMatch(mathInlineMultiLine)
if len(match) < 2 || bytes.Contains(match[1], []byte("<latex")) {
return nil
}
trimmed := bytes.TrimRight(match[1], "\n\r")
if len(trimmed) == 0 || trimmed[len(trimmed)-1] == ' ' || trimmed[len(trimmed)-1] == '\t' {
return nil
}
return &mathInline{content: append([]byte(nil), trimmed...)}
}
func scanMathClose(data []byte, delimiter string) ([]byte, int) {
delim := []byte(delimiter)
for offset := 0; offset < len(data); {
if data[offset] == '\\' && offset+1 < len(data) && data[offset+1] == '$' {
offset += 2
continue
}
rel := bytes.Index(data[offset:], delim)
if rel < 0 {
return nil, -1
}
end := offset + rel
if bytes.Contains(data[:end], []byte("<latex")) {
return nil, -1
}
return data[:end], end + len(delim)
}
return nil, -1
}
type mathExtension struct{}
func (e *mathExtension) Extend(markdown goldmark.Markdown) {
markdown.Parser().AddOptions(parser.WithInlineParsers(
gmutil.Prioritized(&mathInlineParser{}, 100),
))
}
// ---------- Underscore-bearing raw XML tags ----------
type underscoreHTMLExtension struct{}
func (e *underscoreHTMLExtension) Extend(markdown goldmark.Markdown) {
markdown.Parser().AddOptions(
parser.WithInlineParsers(gmutil.Prioritized(&underscoreRawHTMLParser{}, 99)),
parser.WithBlockParsers(gmutil.Prioritized(&underscoreHTMLBlockParser{}, 99)),
)
}
var (
extendedTagNamePattern = `([A-Za-z][A-Za-z0-9_-]*)`
extendedAttributePattern = `(?:\s+[a-zA-Z_:][a-zA-Z0-9:._-]*(?:\s*=\s*(?:[^"'=<>` + "`" + `\x00-\x20]+|'[^']*'|"[^"]*"))?)`
extendedOpenTag = regexp.MustCompile("^<" + extendedTagNamePattern + extendedAttributePattern + `*\s*/?>`)
extendedCloseTag = regexp.MustCompile("^</" + extendedTagNamePattern + `\s*>`)
peekExtendedOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
peekExtendedCloseTag = regexp.MustCompile(`^</([A-Za-z][A-Za-z0-9_-]*)`)
extendedBlockTag = regexp.MustCompile(`^[ ]{0,3}<(/)?\s*([a-zA-Z0-9_\-]+)(` + extendedAttributePattern + `*)\s*(?:>|/>)\s*\n?$`)
)
type underscoreRawHTMLParser struct{}
func (p *underscoreRawHTMLParser) Trigger() []byte { return []byte{'<'} }
func (p *underscoreRawHTMLParser) Parse(_ gast.Node, reader text.Reader, _ parser.Context) gast.Node {
line, _ := reader.PeekLine()
if len(line) > 1 && gmutil.IsAlphaNumeric(line[1]) {
if match := peekExtendedOpenTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
return p.parseMultiLine(extendedOpenTag, reader)
}
return nil
}
if len(line) > 2 && line[1] == '/' && gmutil.IsAlphaNumeric(line[2]) {
if match := peekExtendedCloseTag.FindSubmatch(line); match != nil && bytes.IndexByte(match[1], '_') >= 0 {
return p.parseMultiLine(extendedCloseTag, reader)
}
}
return nil
}
func (p *underscoreRawHTMLParser) parseMultiLine(re *regexp.Regexp, reader text.Reader) gast.Node {
startLine, startSegment := reader.Position()
if !reader.Match(re) {
return nil
}
endLine, endSegment := reader.Position()
reader.SetPosition(startLine, startSegment)
node := gast.NewRawHTML()
for {
line, segment := reader.PeekLine()
if line == nil {
break
}
lineNo, _ := reader.Position()
start := segment.Start
if lineNo == startLine {
start = startSegment.Start
}
end := segment.Stop
if lineNo == endLine {
end = endSegment.Start
}
node.Segments.Append(text.NewSegment(start, end))
if lineNo == endLine {
reader.Advance(end - start)
break
}
reader.AdvanceLine()
}
return node
}
type underscoreHTMLBlockParser struct{}
func (p *underscoreHTMLBlockParser) Trigger() []byte { return []byte{'<'} }
func (p *underscoreHTMLBlockParser) Open(_ gast.Node, reader text.Reader, pc parser.Context) (gast.Node, parser.State) {
line, segment := reader.PeekLine()
pos := pc.BlockOffset()
if pos < 0 || pos >= len(line) || line[pos] != '<' {
return nil, parser.NoChildren
}
match := extendedBlockTag.FindSubmatchIndex(line)
if match == nil {
return nil, parser.NoChildren
}
tag := string(line[match[4]:match[5]])
if !strings.Contains(tag, "_") {
return nil, parser.NoChildren
}
isClose := match[2] > -1 && bytes.Equal(line[match[2]:match[3]], []byte("/"))
hasAttrs := match[6] != match[7]
if isClose && hasAttrs {
return nil, parser.NoChildren
}
node := gast.NewHTMLBlock(gast.HTMLBlockType7)
node.Lines().Append(segment)
reader.Advance(segment.Len() - 1)
return node, parser.NoChildren
}
func (p *underscoreHTMLBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
line, segment := reader.PeekLine()
if gmutil.IsBlank(line) {
return parser.Close
}
node.Lines().Append(segment)
reader.Advance(segment.Len() - 1)
return parser.Continue | parser.NoChildren
}
func (p *underscoreHTMLBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
func (p *underscoreHTMLBlockParser) CanInterruptParagraph() bool { return false }
func (p *underscoreHTMLBlockParser) CanAcceptIndentedLine() bool { return false }
// ---------- Markdown-aware DocxXML containers ----------
type containerSpec struct {
tag string
}
var containerSpecs = map[string]*containerSpec{
"callout": {tag: "callout"},
"grid": {tag: "grid"},
"column": {tag: "column"},
"div": {tag: "div"},
}
var kindContainerBlock = gast.NewNodeKind("DocxContainerBlock")
type containerBlock struct {
gast.BaseBlock
spec *containerSpec
attrs map[string]string
}
func (n *containerBlock) Kind() gast.NodeKind { return kindContainerBlock }
func (n *containerBlock) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, nil, nil)
}
type containerBlockParser struct{}
func (p *containerBlockParser) Trigger() []byte { return []byte{'<'} }
var containerOpenTag = regexp.MustCompile(`^<([A-Za-z][A-Za-z0-9_-]*)`)
func (p *containerBlockParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) {
line, _ := reader.PeekLine()
trimmed := bytes.TrimLeft(line, " \t")
leading := len(line) - len(trimmed)
if len(trimmed) < 2 || trimmed[0] != '<' {
return nil, parser.NoChildren
}
match := containerOpenTag.FindSubmatch(trimmed)
if match == nil {
return nil, parser.NoChildren
}
spec := containerSpecs[strings.ToLower(string(match[1]))]
if spec == nil {
return nil, parser.NoChildren
}
openEnd := bytes.IndexByte(trimmed, '>')
if openEnd < 0 || openEnd >= 1 && trimmed[openEnd-1] == '/' {
return nil, parser.NoChildren
}
tagEnd := len(match[0])
node := &containerBlock{spec: spec, attrs: parseAttributes(string(trimmed[tagEnd:openEnd]))}
reader.Advance(leading + openEnd + 1)
return node, parser.HasChildren
}
func (p *containerBlockParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State {
container := node.(*containerBlock)
line, segment := reader.PeekLine()
trimmed := bytes.TrimLeft(line, " \t")
if hasCloseTagPrefix(trimmed, container.spec.tag) {
reader.Advance(len(line) - len(trimmed) + closeTagLength(container.spec.tag))
return parser.Close
}
if isXMLTagLine(trimmed) {
indent := len(line) - len(trimmed)
if indent > 0 && segment.Start+indent <= segment.Stop {
reader.AdvanceAndSetPadding(indent, 0)
}
}
return parser.Continue | parser.HasChildren
}
func (p *containerBlockParser) Close(gast.Node, text.Reader, parser.Context) {}
func (p *containerBlockParser) CanInterruptParagraph() bool { return true }
func (p *containerBlockParser) CanAcceptIndentedLine() bool { return true }
func closeTagLength(tag string) int { return len(tag) + len("</>") }
func hasCloseTagPrefix(line []byte, tag string) bool {
want := []byte("</" + tag + ">")
return len(line) >= len(want) && bytes.EqualFold(line[:len(want)], want)
}
func isXMLTagLine(line []byte) bool {
if len(line) < 2 || line[0] != '<' {
return false
}
if line[1] == '/' {
return len(line) >= 3 && isASCIILetter(line[2])
}
return isASCIILetter(line[1])
}
func isASCIILetter(ch byte) bool {
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
}

View File

@@ -1,172 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package docxparse parses LarkOpenCLI DocxXML and Markdown into a small,
// offline DOM for the docs +script shortcut.
package docxparse
import (
"sort"
"strings"
)
// Format is an accepted source document format.
type Format string
const (
FormatXML Format = "xml"
FormatMarkdown Format = "markdown"
)
// ParseResult is the complete result returned by Parse.
type ParseResult struct {
Format Format `json:"format"`
XML string `json:"xml"`
Profile Profile `json:"profile"`
}
type nodeType uint8
const (
nodeText nodeType = iota
nodeElement
)
// Node is the internal DocxXML DOM representation.
type Node struct {
typ nodeType
tag string
attrs map[string]string
children []*Node
text string
parent *Node
}
func newText(text string) *Node {
return &Node{typ: nodeText, text: text}
}
func newElement(tag string, attrs map[string]string) *Node {
return &Node{typ: nodeElement, tag: tag, attrs: attrs}
}
func (n *Node) addChild(child *Node) {
if n == nil || child == nil {
return
}
child.parent = n
n.children = append(n.children, child)
}
func (n *Node) writeXML(out *strings.Builder) {
if n == nil {
return
}
if n.typ == nodeText {
out.WriteString(escapeXMLText(n.text))
return
}
out.WriteByte('<')
out.WriteString(n.tag)
keys := make([]string, 0, len(n.attrs))
for key := range n.attrs {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
wi, iWeighted := attributeWeight[keys[i]]
wj, jWeighted := attributeWeight[keys[j]]
switch {
case iWeighted && jWeighted && wi != wj:
return wi < wj
case iWeighted != jWeighted:
return iWeighted
default:
return keys[i] < keys[j]
}
})
for _, key := range keys {
out.WriteByte(' ')
out.WriteString(key)
out.WriteString(`="`)
out.WriteString(escapeXMLAttr(n.attrs[key]))
out.WriteByte('"')
}
if isVoidTag(n.tag) {
out.WriteString("/>")
return
}
out.WriteByte('>')
for _, child := range n.children {
child.writeXML(out)
}
out.WriteString("</")
out.WriteString(n.tag)
out.WriteByte('>')
}
func renderNodes(nodes []*Node) string {
var out strings.Builder
for _, node := range nodes {
node.writeXML(&out)
}
return out.String()
}
var attributeWeight = map[string]int{
"id": 0,
"name": 1,
"top-block-id": 2,
"parent-block-path": 3,
"mode": 4,
"start-block-id": 5,
"end-block-id": 6,
"hit-block-ids": 7,
}
func escapeXMLText(value string) string {
if !strings.ContainsAny(value, "&<>") {
return value
}
var out strings.Builder
out.Grow(len(value) + 8)
for _, r := range value {
switch r {
case '&':
out.WriteString("&amp;")
case '<':
out.WriteString("&lt;")
case '>':
out.WriteString("&gt;")
default:
out.WriteRune(r)
}
}
return out.String()
}
func escapeXMLAttr(value string) string {
if !strings.ContainsAny(value, "&<>\"'") {
return value
}
var out strings.Builder
out.Grow(len(value) + 8)
for _, r := range value {
switch r {
case '&':
out.WriteString("&amp;")
case '<':
out.WriteString("&lt;")
case '>':
out.WriteString("&gt;")
case '"':
out.WriteString("&#34;")
case '\'':
out.WriteString("&#39;")
default:
out.WriteRune(r)
}
}
return out.String()
}

View File

@@ -1,488 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
import (
"strings"
"testing"
)
func TestParseXMLBuildsBlockDistribution(t *testing.T) {
result, err := Parse(`<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>`, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != `<title>T</title><p>P</p><ul><li>A</li><li>B</li></ul>` {
t.Fatalf("XML = %q", result.XML)
}
if result.Profile.BlockCount != 5 {
t.Fatalf("block total = %d, want 5", result.Profile.BlockCount)
}
shares := map[string]BlockShare{}
for _, share := range result.Profile.Blocks {
shares[share.Type] = share
}
if got := shares["li"]; got.Count != 2 || got.Ratio != 0.4 {
t.Fatalf("li share = %+v, want count=2 ratio=0.4", got)
}
for _, typ := range []string{"title", "p", "ul"} {
if got := shares[typ]; got.Count != 1 || got.Ratio != 0.2 {
t.Errorf("%s share = %+v, want count=1 ratio=0.2", typ, got)
}
}
}
func TestParseXMLRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
source string
}{
{name: "unsupported tag", source: `<unknown>x</unknown>`},
{name: "missing closing tag", source: `<p>one`},
{name: "invalid nesting", source: `<span>x<table><tr><td>y</td></tr></table></span>`},
{name: "malformed block id", source: `<block_id="8,9"/>`},
{name: "unterminated cdata", source: `<code><![CDATA[a < b</code>`},
{name: "tag spacing", source: `< p>text< / p>`},
{name: "self closing slash spacing", source: `<p/ >`},
{name: "unquoted attribute", source: `<p align=center>text</p>`},
{name: "invalid entity", source: `<p>one &unknown;</p>`},
{name: "invalid attribute entity", source: `<img href="https://example.com/&unknown;"/>`},
{name: "missing required ancestor", source: `<td>cell</td>`},
{name: "missing required attribute", source: `<img/>`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := Parse(tt.source, FormatXML); err == nil {
t.Fatalf("Parse(%q) succeeded, want validation error", tt.source)
}
})
}
}
func TestParseAutoDetectsXMLAndMarkdown(t *testing.T) {
tests := []struct {
name string
source string
blocks int
}{
{name: "xml", source: `<title>T</title><p>P</p>`, blocks: 2},
{name: "markdown", source: "# T\n\nP", blocks: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
profile, err := ParseAuto(tt.source)
if err != nil {
t.Fatalf("ParseAuto() error = %v", err)
}
if profile.BlockCount != tt.blocks {
t.Fatalf("profile = %+v, want %d blocks", profile, tt.blocks)
}
})
}
}
func TestParseAutoDoesNotTreatMalformedXMLAsMarkdown(t *testing.T) {
if _, err := ParseAuto(`<p>text`); err == nil {
t.Fatal("ParseAuto() succeeded, want malformed XML error")
}
}
func TestParseXMLAcceptsPublicTagAliasesWithoutChangingInput(t *testing.T) {
source := `<P>one<strong>two</strong><br></P><image href="https://example.com/image.png">`
result, err := Parse(source, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != source {
t.Fatalf("XML = %q, want original %q", result.XML, source)
}
if result.Profile.BlockCount != 2 {
t.Fatalf("profile = %+v, want p and img blocks", result.Profile)
}
}
func TestParseXMLAcceptsPublicAttributeAliasesWithoutChangingInput(t *testing.T) {
source := `<callout color="blue" icon="💡"><p>x</p></callout><at id="ou_legacy"></at><img url="https://example.com/image.png"/>`
result, err := Parse(source, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != source {
t.Fatalf("XML = %q, want original %q", result.XML, source)
}
if result.Profile.BlockCount != 3 {
t.Fatalf("profile = %+v, want callout, p, and img blocks", result.Profile)
}
}
func TestParseXMLAcceptsBareAmpersandsInAttributes(t *testing.T) {
source := `<block_insert><parameter><block_id>-1</block_id><content><img href="https://picsum.photos/320/200?seed=lark-cli&raw=1"/></content></parameter></block_insert>`
result, err := Parse(source, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != source {
t.Fatalf("XML = %q, want original %q", result.XML, source)
}
}
func TestNormalizeXMLAttributeAmpersandsPreservesEntityReferences(t *testing.T) {
source := `https://example.com?a=1&b=2&amp;c=3&#38;d=4&#x26;e=5&unknown;`
want := `https://example.com?a=1&amp;b=2&amp;c=3&#38;d=4&#x26;e=5&unknown;`
if got := normalizeXMLAttributeAmpersands(source); got != want {
t.Fatalf("normalizeXMLAttributeAmpersands() = %q, want %q", got, want)
}
}
func TestParseXMLPreservesValidCDATA(t *testing.T) {
source := `<code><![CDATA[a < b && c > d]]></code>`
result, err := Parse(source, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != source {
t.Fatalf("XML = %q, want original %q", result.XML, source)
}
}
func TestParseXMLPreservesUTF8BOM(t *testing.T) {
source := "\uFEFF<p>text</p>"
result, err := Parse(source, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != source {
t.Fatalf("XML = %q, want original input", result.XML)
}
}
func TestParseMarkdownConvertsLarkOpenCLIBlocks(t *testing.T) {
source := "# 标题\n\nHello **world**.\n\n- [x] Done\n- [ ] Todo\n\n" +
"| A | B |\n| --- | --- |\n| 1 | 2 |\n\n" +
"```go\nfmt.Println(\"x\")\n```\n\n$E=mc^2$\n"
result, err := Parse(source, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
for _, fragment := range []string{
`<h1>标题</h1>`,
`<p>Hello <b>world</b>.</p>`,
`<checkbox done="true">Done</checkbox>`,
`<checkbox done="false">Todo</checkbox>`,
`<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>`,
`<pre lang="go"><code>fmt.Println("x")</code></pre>`,
`<p><latex>E=mc^2</latex></p>`,
} {
if !strings.Contains(result.XML, fragment) {
t.Errorf("XML missing %q:\n%s", fragment, result.XML)
}
}
}
func TestParseMarkdownPreservesLineBreakSemantics(t *testing.T) {
tests := []struct {
name string
source string
want string
}{
{
name: "soft breaks become spaces",
source: "**文号桂汛旱指202617号**\n**签发人XXX**\n**发布日期2026年7月13日**",
want: `<p><b>文号桂汛旱指202617号</b> <b>签发人XXX</b> <b>发布日期2026年7月13日</b></p>`,
},
{
name: "hard breaks remain line breaks",
source: "**文号A** \n**签发人B**",
want: `<p><b>文号A</b><br/><b>签发人B</b></p>`,
},
{
name: "blank lines remain paragraph breaks",
source: "**文号A**\n\n**签发人B**",
want: `<p><b>文号A</b></p><p><b>签发人B</b></p>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(tt.source, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != tt.want {
t.Fatalf("XML = %q, want %q", result.XML, tt.want)
}
})
}
}
func TestParseMarkdownContainerKeepsMarkdownChildren(t *testing.T) {
source := "<callout emoji=\"💡\">\n\n## Note\n\n- item\n\n</callout>\n"
result, err := Parse(source, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
want := `<callout emoji="💡"><h2>Note</h2><ul><li>item</li></ul></callout>`
if result.XML != want {
t.Fatalf("XML = %q, want %q", result.XML, want)
}
}
func TestParseMarkdownMatchesLarkOpenCLIFixtures(t *testing.T) {
t.Run("deep nested list", func(t *testing.T) {
result, err := Parse("1. 第一层\n - 第二层\n - 第三层\n - 第四层\n", FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if strings.Contains(result.XML, "<pre>") || strings.Contains(result.XML, "<code>") || !strings.Contains(result.XML, "第四层") {
t.Fatalf("nested list converted incorrectly: %s", result.XML)
}
})
t.Run("fenced mermaid", func(t *testing.T) {
result, err := Parse("```mermaid\nflowchart LR\nA-->B\n```", FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
want := `<whiteboard type="mermaid">flowchart LR<br/>A--&gt;B</whiteboard>`
if result.XML != want {
t.Fatalf("XML = %q, want %q", result.XML, want)
}
})
t.Run("raw whiteboard source", func(t *testing.T) {
source := "<whiteboard type=\"mermaid\">\nflowchart LR\n A --> B\n</whiteboard>"
result, err := Parse(source, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
want := `<whiteboard type="mermaid">flowchart LR<br/> A --&gt; B</whiteboard>`
if result.XML != want {
t.Fatalf("XML = %q, want %q", result.XML, want)
}
})
t.Run("raw code stays literal", func(t *testing.T) {
source := "<code lang=\"go\">\nif a < b && c > d {\n fmt.Println(\"**raw**\")\n}\n</code>"
result, err := Parse(source, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
want := `<code lang="go">if a &lt; b &amp;&amp; c &gt; d {<br/> fmt.Println("**raw**")<br/>}</code>`
if result.XML != want {
t.Fatalf("XML = %q, want %q", result.XML, want)
}
})
t.Run("underscore tags", func(t *testing.T) {
result, err := Parse(`text <synced_reference src-block-id="abc" src-token="def"/> more`, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if !strings.Contains(result.XML, `<synced_reference`) || strings.Contains(result.XML, `&lt;synced_reference`) {
t.Fatalf("underscore tag was not preserved: %s", result.XML)
}
})
t.Run("canonical user cite", func(t *testing.T) {
result, err := Parse(`hello <cite type="user" user-id="ou_user"></cite>`, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
for _, want := range []string{`<cite`, `type="user"`, `user-id="ou_user"`} {
if !strings.Contains(result.XML, want) {
t.Errorf("XML missing %q: %s", want, result.XML)
}
}
})
t.Run("public tag alias converts to canonical XML", func(t *testing.T) {
result, err := Parse(`hello <strong>world</strong>`, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != `<p>hello <b>world</b></p>` {
t.Fatalf("XML = %q", result.XML)
}
})
t.Run("public cite alias converts attributes", func(t *testing.T) {
result, err := Parse(`hello <at id="ou_legacy"></at>`, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != `<p>hello <cite type="user" user-id="ou_legacy"></cite></p>` {
t.Fatalf("XML = %q", result.XML)
}
})
t.Run("markdown backslash escapes", func(t *testing.T) {
result, err := Parse(`"source\_token": \[abc\] path\\to`, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
for _, want := range []string{`source_token`, `[abc]`, `path\to`} {
if !strings.Contains(result.XML, want) {
t.Errorf("XML missing %q: %s", want, result.XML)
}
}
})
t.Run("adjacent CJK emphasis", func(t *testing.T) {
source := `***你好。***S 和 ~~再见。~~T。**agent team 做 brownfield 项目,带来的感知会强烈得多**——前提。**这个时刻,才是真正属于 agent team 的"闪光时刻"。**翟霖`
result, err := Parse(source, FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
for _, want := range []string{
`<em><b>你好。</b></em>S`,
`<del>再见。</del>T`,
`<b>agent team 做 brownfield 项目,带来的感知会强烈得多</b>`,
`<b>这个时刻,才是真正属于 agent team 的"闪光时刻"。</b>翟霖`,
} {
if !strings.Contains(result.XML, want) {
t.Errorf("XML missing %q: %s", want, result.XML)
}
}
})
t.Run("div parses markdown children", func(t *testing.T) {
result, err := Parse("<div>\n\n**bold**\n\n</div>", FormatMarkdown)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if result.XML != `<div><p><b>bold</b></p></div>` {
t.Fatalf("XML = %q", result.XML)
}
})
}
func TestPreprocessCJKAdjacentMarkupUsesRuneOffsetsAfterRawBlock(t *testing.T) {
tests := []struct {
name string
lineEnding string
final string
}{
{name: "EOF", lineEnding: "\n"},
{name: "LF", lineEnding: "\n", final: "\n"},
{name: "CRLF", lineEnding: "\r\n", final: "\r\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
source := "<code>**raw**" + tt.lineEnding + "Ⱥ</code>**你好。**S" + tt.final
want := "<code>**raw**" + tt.lineEnding + "Ⱥ</code><b>你好。</b>S" + tt.final
if got := preprocessCJKAdjacentMarkup(source); got != want {
t.Fatalf("preprocessCJKAdjacentMarkup() = %q, want %q", got, want)
}
})
}
}
func TestTextProfileMatchesLarkOpenCLIContract(t *testing.T) {
result, err := Parse(`<title>标题</title><p>一个苹果是 an apple。</p>`, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
profile := result.Profile
if profile.WordCount != 10 || profile.CharCount != 15 {
t.Fatalf("profile = %+v, want word_count=10 char_count=15", profile)
}
if profile.Breakdown.HanChars != 7 || profile.Breakdown.EnglishWords != 2 || profile.Breakdown.ChinesePunctuations != 1 {
t.Fatalf("breakdown = %+v", profile.Breakdown)
}
}
func TestTextProfileMatchesAuthoringCounterCases(t *testing.T) {
tests := []struct {
name string
source string
words int
chars int
blocks int
english int
numbers int
han int
listItems int
}{
{
name: "english number and punctuation",
source: `<p>Hello world 123.45。</p>`,
words: 4, chars: 17, blocks: 1, english: 2, numbers: 1,
},
{
name: "list and checkbox markers",
source: `<ul><li>甲</li><li>two</li></ul><checkbox done="true">完成</checkbox>`,
words: 7, chars: 9, blocks: 4, english: 1, han: 3, listItems: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Parse(tt.source, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
profile := result.Profile
if profile.WordCount != tt.words || profile.CharCount != tt.chars || profile.BlockCount != tt.blocks {
t.Fatalf("profile = %+v, want words=%d chars=%d blocks=%d", profile, tt.words, tt.chars, tt.blocks)
}
if profile.Breakdown.EnglishWords != tt.english || profile.Breakdown.NumberWords != tt.numbers || profile.Breakdown.HanChars != tt.han {
t.Fatalf("breakdown = %+v", profile.Breakdown)
}
if got := blockCountForTest(profile.Blocks, "li"); got != tt.listItems {
t.Fatalf("li count = %d, want %d", got, tt.listItems)
}
})
}
}
func TestTextProfileUsesVisibleAttributeFallbacks(t *testing.T) {
result, err := Parse(`<p text="Hello"/><p><span title="world"/></p><img href="https://example.com/image.png" caption="图"/>`, FormatXML)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
profile := result.Profile
if profile.WordCount != 3 || profile.CharCount != 11 {
t.Fatalf("profile = %+v, want word_count=3 char_count=11", profile)
}
if profile.Breakdown.EnglishWords != 2 || profile.Breakdown.HanChars != 1 {
t.Fatalf("breakdown = %+v", profile.Breakdown)
}
}
func TestParseRejectsUnsafeXMLDeclarations(t *testing.T) {
_, err := Parse(`<!DOCTYPE foo [<!ENTITY x "value">]><p>&x;</p>`, FormatXML)
if err == nil || !strings.Contains(err.Error(), "DOCTYPE or ENTITY") {
t.Fatalf("Parse() error = %v, want unsafe declaration rejection", err)
}
}
func TestParseRejectsInvalidUTF8(t *testing.T) {
_, err := Parse(string([]byte{'<', 'p', '>', 0xff, '<', '/', 'p', '>'}), FormatXML)
if err == nil || !strings.Contains(err.Error(), "valid UTF-8") {
t.Fatalf("Parse() error = %v, want UTF-8 rejection", err)
}
}
func TestParseRejectsExcessiveNesting(t *testing.T) {
source := strings.Repeat("<span>", MaxNestingDepth+1)
_, err := Parse(source, FormatXML)
if err == nil || !strings.Contains(err.Error(), "nesting exceeds") {
t.Fatalf("Parse() error = %v, want nesting limit rejection", err)
}
}
func TestParseXMLRejectsNestedInvalidTagStarts(t *testing.T) {
if _, err := Parse(`<<<<p>text</p>`, FormatXML); err == nil {
t.Fatal("Parse() succeeded, want invalid XML token error")
}
}
func blockCountForTest(blocks []BlockShare, typ string) int {
for _, block := range blocks {
if block.Type == typ {
return block.Count
}
}
return 0
}

View File

@@ -1,397 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
import (
"fmt"
"math"
"sort"
"strings"
)
// Profile describes LarkOpenCLI document structure and visible text without
// requiring callers to inspect the full XML.
type Profile struct {
WordCount int `json:"word_count"`
CharCount int `json:"char_count"`
Breakdown TextBreakdown `json:"breakdown"`
BlockCount int `json:"block_count"`
Blocks []BlockShare `json:"blocks"`
}
// BlockShare reports one LarkOpenCLI block type's count and share. Structural
// and inline-only tags are intentionally excluded.
type BlockShare struct {
Type string `json:"type"`
Count int `json:"count"`
Ratio float64 `json:"ratio"`
}
// TextProfile is the internal result of the LarkOpenCLI semantic counter.
type TextProfile struct {
WordCount int `json:"word_count"`
CharCount int `json:"char_count"`
Breakdown TextBreakdown `json:"breakdown"`
}
type TextBreakdown struct {
HanChars int `json:"han_chars"`
EnglishWords int `json:"english_words"`
NumberWords int `json:"number_words"`
ChinesePunctuations int `json:"chinese_punctuations"`
EnglishLetters int `json:"english_letters"`
Digits int `json:"digits"`
EnglishPunctuations int `json:"english_punctuations"`
SymbolWords int `json:"symbol_words"`
SymbolChars int `json:"symbol_chars"`
}
// Parse validates XML or converts Markdown to DocxXML, then builds its
// structure and visible-text profile.
func Parse(source string, format Format) (ParseResult, error) {
var (
nodes []*Node
outputXML string
err error
)
switch format {
case FormatXML:
nodes, err = parseXML(source)
outputXML = source
case FormatMarkdown:
nodes, err = parseMarkdown(source)
default:
return ParseResult{}, fmt.Errorf("unsupported input format %q", format)
}
if err != nil {
return ParseResult{}, err
}
if err := validateStructure(nodes); err != nil {
return ParseResult{}, err
}
if format == FormatMarkdown {
outputXML = renderNodes(nodes)
}
return ParseResult{
Format: format,
XML: outputXML,
Profile: buildProfile(nodes),
}, nil
}
// ParseAuto detects XML versus Markdown from the content and returns only the
// document profile. XML-like input is parsed strictly; all other input is
// interpreted as Markdown.
func ParseAuto(source string) (Profile, error) {
result, err := Parse(source, detectFormat(source))
if err != nil {
return Profile{}, err
}
return result.Profile, nil
}
// MarkdownToXML converts Markdown to canonical LarkOpenCLI XML.
func MarkdownToXML(source string) (string, error) {
result, err := Parse(source, FormatMarkdown)
if err != nil {
return "", err
}
return result.XML, nil
}
func detectFormat(source string) Format {
trimmed := strings.TrimSpace(strings.TrimPrefix(source, "\uFEFF"))
if strings.HasPrefix(trimmed, "<") {
return FormatXML
}
return FormatMarkdown
}
func validateStructure(nodes []*Node) error {
type frame struct {
node *Node
exit bool
}
frames := make([]frame, 0, len(nodes))
for i := len(nodes) - 1; i >= 0; i-- {
frames = append(frames, frame{node: nodes[i]})
}
ancestors := map[string]int{}
depth := 0
for len(frames) > 0 {
current := frames[len(frames)-1]
frames = frames[:len(frames)-1]
node := current.node
if node == nil || node.typ != nodeElement {
continue
}
if current.exit {
ancestors[node.tag]--
depth--
continue
}
if depth >= MaxNestingDepth {
return fmt.Errorf("document nesting exceeds limit %d at <%s>", MaxNestingDepth, node.tag)
}
if err := validateRequiredAttributes(node); err != nil {
return err
}
if required := requiredAncestorTags[node.tag]; len(required) > 0 {
matched := false
for tag := range required {
if ancestors[tag] > 0 {
matched = true
break
}
}
if !matched {
allowed := make([]string, 0, len(required))
for tag := range required {
allowed = append(allowed, tag)
}
sort.Strings(allowed)
return fmt.Errorf("LarkOpenCLI tag <%s> requires an ancestor in [%s]", node.tag, strings.Join(allowed, ", "))
}
}
ancestors[node.tag]++
depth++
frames = append(frames, frame{node: node, exit: true})
for i := len(node.children) - 1; i >= 0; i-- {
frames = append(frames, frame{node: node.children[i]})
}
}
return nil
}
func validateRequiredAttributes(node *Node) error {
for _, attr := range requiredAttributes[node.tag] {
if strings.TrimSpace(node.attrs[attr]) == "" {
return fmt.Errorf("LarkOpenCLI tag <%s> requires attribute %q", node.tag, attr)
}
}
for _, alternatives := range requiredAnyAttributes[node.tag] {
matched := false
for _, attr := range alternatives {
if strings.TrimSpace(node.attrs[attr]) != "" {
matched = true
break
}
}
if !matched {
return fmt.Errorf("LarkOpenCLI tag <%s> requires one of attributes [%s]", node.tag, strings.Join(alternatives, ", "))
}
}
return nil
}
func buildProfile(nodes []*Node) Profile {
counts := map[string]int{}
total := 0
var walk func(*Node)
walk = func(node *Node) {
if node == nil || node.typ != nodeElement {
return
}
layout := layoutOf(node.tag)
isBlock := layout == layoutBlock || layout == layoutDual && node.parent == nil
if isBlock {
counts[node.tag]++
total++
}
for _, child := range node.children {
walk(child)
}
}
for _, node := range nodes {
walk(node)
}
distribution := make([]BlockShare, 0, len(counts))
for typ, count := range counts {
ratio := 0.0
if total > 0 {
ratio = math.Round(float64(count)/float64(total)*1_000_000) / 1_000_000
}
distribution = append(distribution, BlockShare{Type: typ, Count: count, Ratio: ratio})
}
sort.Slice(distribution, func(i, j int) bool {
if distribution[i].Count != distribution[j].Count {
return distribution[i].Count > distribution[j].Count
}
return distribution[i].Type < distribution[j].Type
})
segments := extractSegments(nodes)
stats := newTextCounter().countSegments(segments)
return Profile{
WordCount: stats.WordCount,
CharCount: stats.CharCount,
Breakdown: stats.Breakdown,
BlockCount: total,
Blocks: distribution,
}
}
type segmentKind uint8
const (
segmentText segmentKind = iota
segmentMarker
segmentCode
)
type textSegment struct {
text string
kind segmentKind
}
var ignoredResourceTags = map[string]bool{
"whiteboard": true, "sheet": true, "source": true, "chat_card": true,
"base_refer": true, "bitable": true, "synced_reference": true,
"poll": true, "isv": true, "mindnote": true, "sub-page-list": true,
"okr": true, "html5-block": true,
}
var ignoredInlineTags = map[string]bool{
"button": true, "cite": true, "latex": true, "bookmark": true,
}
func extractSegments(nodes []*Node) []textSegment {
var segments []textSegment
for _, node := range nodes {
extractNodeSegments(node, &segments)
}
return segments
}
func extractNodeSegments(node *Node, segments *[]textSegment) {
if node == nil {
return
}
if node.typ == nodeText {
if strings.TrimSpace(node.text) != "" {
*segments = append(*segments, textSegment{text: node.text})
}
return
}
if ignoredInlineTags[node.tag] || ignoredResourceTags[node.tag] {
return
}
if node.tag == "task" {
return
}
if node.tag == "synced-source" && len(node.children) == 0 {
return
}
switch node.tag {
case "ul", "ol":
sequence := 1
for _, child := range node.children {
if child.typ == nodeElement && child.tag == "li" {
if node.tag == "ul" {
*segments = append(*segments, textSegment{text: "•", kind: segmentMarker})
} else {
marker := sequence
if raw := child.attrs["seq"]; raw != "" {
if _, err := fmt.Sscanf(raw, "%d", &marker); err == nil {
sequence = marker
}
}
*segments = append(*segments, textSegment{text: fmt.Sprintf("%d.", marker)})
sequence++
}
}
extractNodeSegments(child, segments)
}
return
case "checkbox":
marker := "☐"
if node.attrs["done"] == "true" {
marker = "☑"
}
*segments = append(*segments, textSegment{text: marker, kind: segmentMarker})
}
kind := segmentText
if node.tag == "pre" || node.tag == "code" && (node.parent == nil || node.parent.tag != "p") {
kind = segmentCode
}
text := visibleInlineText(node)
if strings.TrimSpace(text) == "" && !hasBlockChildren(node) {
if node.tag == "img" {
text = node.attrs["caption"]
} else {
text = firstNonEmpty(node.attrs["text"], node.attrs["name"], node.attrs["title"], node.attrs["alt"], node.attrs["caption"])
}
}
if strings.TrimSpace(text) != "" {
*segments = append(*segments, textSegment{text: text, kind: kind})
}
for _, child := range node.children {
if child.typ != nodeElement || isInlineForExtraction(child.tag) {
continue
}
extractNodeSegments(child, segments)
}
}
func visibleInlineText(node *Node) string {
var out strings.Builder
var walk func(*Node)
walk = func(current *Node) {
if current.typ == nodeText {
out.WriteString(current.text)
return
}
if current != node && !isInlineForExtraction(current.tag) {
return
}
if ignoredInlineTags[current.tag] {
return
}
if current.tag == "br" {
out.WriteByte('\n')
return
}
if current != node {
if display := firstNonEmpty(current.attrs["text"], current.attrs["name"], current.attrs["title"], current.attrs["alt"]); display != "" {
out.WriteString(display)
return
}
}
for _, child := range current.children {
walk(child)
}
}
for _, child := range node.children {
walk(child)
}
return out.String()
}
func hasBlockChildren(node *Node) bool {
for _, child := range node.children {
if child.typ == nodeElement && !isInlineForExtraction(child.tag) {
return true
}
}
return false
}
func isInlineForExtraction(tag string) bool {
layout := layoutOf(tag)
return layout == layoutInline || layout == layoutDual
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -1,268 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
import (
"sort"
"strconv"
"strings"
)
type tagLayout string
const (
layoutBlock tagLayout = "block"
layoutInline tagLayout = "inline"
layoutDual tagLayout = "dual"
layoutStructural tagLayout = "structural"
layoutCommand tagLayout = "command"
)
type tagSpec struct {
canonical string
layout tagLayout
}
var tagSpecs = map[string]tagSpec{}
// tagAliases mirrors the public compatibility aliases declared by the
// LarkOpenCLI SDK. Parsing keeps the caller's XML unchanged; aliases are only
// canonicalized in the in-memory tree used for profiling and Markdown output.
var tagAliases = map[string]string{
"strong": "b",
"text": "span",
"equation": "latex",
"lark-table": "table",
"lark-tr": "tr",
"lark-td": "td",
"image": "img",
"reference-synced": "synced_reference",
"source-synced": "synced-source",
"at": "cite",
"chat-card": "chat_card",
"folder_manager": "folder-manager",
}
type attributeAliasRule struct {
canonical string
transform func(string) (string, bool)
}
var commonAttributeAliases = map[string]attributeAliasRule{
"color": {canonical: "text-color"},
"textcolor": {canonical: "text-color"},
"text_color": {canonical: "text-color"},
"bgcolor": {canonical: "background-color"},
"background_color": {canonical: "background-color"},
}
var tagAttributeAliases = map[string]map[string]attributeAliasRule{
"img": {
"url": {canonical: "href"},
"file_key": {canonical: "img_key"},
},
"callout": {
"color": {canonical: "background-color"},
"icon": {canonical: "emoji"},
},
"column": {
"width": {canonical: "width-ratio", transform: normalizeWidthRatio},
},
"chat_card": {
"id": {canonical: "chat-id", transform: requireChatID},
},
"cite": {
"user_id": {canonical: "user-id"},
},
}
var rawTagAttributeAliases = map[string]map[string]attributeAliasRule{
"at": {
"id": {canonical: "user-id"},
"user_id": {canonical: "user-id"},
},
}
var requiredAttributes = map[string][]string{
"task": {"task-id"},
}
var requiredAnyAttributes = map[string][][]string{
"img": {{"src", "img_key", "href"}},
"whiteboard": {{"token", "type"}},
"chat_card": {{"token", "chat-id"}},
"bookmark": {{"href", "name"}},
}
func init() {
registerTags(layoutBlock,
"title", "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "p",
"div", "ul", "ol", "li", "blockquote", "grid", "column", "table", "thead",
"tbody", "tfoot", "tr", "hr", "pre", "img", "source", "bitable", "sheet",
"mindnote", "whiteboard", "base_refer", "synced_reference", "isv", "html5-block",
"view", "synced-source", "readonly-block", "figure", "callout", "checkbox",
"chat_card", "okr", "okr-objective", "okr-key-result", "okr-progress", "poll",
"agenda", "folder-manager", "sub-page-list", "wiki_catalog", "wiki_recent_update",
"chart-embedded", "chart-refer-host-perm", "chart_embedded", "chart_refer_host_perm",
"bookmark", "task", "vc-tabs", "vc-summary-tab", "vc-transcribe-tab", "append",
)
registerTags(layoutInline, "b", "em", "u", "del", "i", "span", "br", "inline-file", "mention-date", "cite", "button", "time", "a")
registerTags(layoutDual, "latex", "code")
registerTags(layoutStructural, "th", "td", "colgroup", "col", "sub-page")
registerTags(layoutCommand,
"comment", "block_delete", "str_delete", "str_replace", "block_replace", "block_insert",
"block_move", "block_copy_insert_after", "src_block_ids", "create", "answer", "response",
"identifier", "genre", "anchor", "type", "revision", "pattern", "replacement",
"replace_content", "action", "content", "parameter", "generation", "block_id",
)
}
func registerTags(layout tagLayout, tags ...string) {
for _, tag := range tags {
tagSpecs[tag] = tagSpec{canonical: tag, layout: layout}
}
}
func lookupTag(raw string) (tagSpec, bool) {
key := strings.ToLower(strings.TrimSpace(raw))
if canonical, ok := tagAliases[key]; ok {
key = canonical
}
spec, ok := tagSpecs[key]
if !ok {
return tagSpec{}, false
}
return spec, true
}
func layoutOf(tag string) tagLayout {
spec, ok := lookupTag(tag)
if !ok {
return ""
}
return spec.layout
}
var voidTags = map[string]bool{
"br": true,
"col": true,
"hr": true,
"img": true,
"source": true,
"sub-page": true,
}
func isVoidTag(tag string) bool { return voidTags[tag] }
var preserveSpaceTags = map[string]bool{
"title": true, "h1": true, "h2": true, "h3": true, "h4": true,
"h5": true, "h6": true, "h7": true, "h8": true, "h9": true,
"p": true, "i": true, "b": true, "em": true, "u": true, "del": true,
"code": true, "li": true, "a": true, "span": true,
}
var strictPhrasingTags = map[string]bool{
"title": true, "span": true, "b": true, "em": true,
"u": true, "del": true, "a": true,
}
var autoCloseTags = map[string]map[string]bool{
"li": {"li": true},
"tr": {"tr": true},
"td": {"td": true, "th": true, "tr": true, "tbody": true, "tfoot": true},
"th": {"th": true, "td": true, "tr": true, "tbody": true, "tfoot": true},
"tbody": {"tbody": true, "tfoot": true},
"thead": {"tbody": true, "tfoot": true},
"column": {"column": true},
}
var requiredAncestorTags = map[string]map[string]bool{
"column": {"grid": true},
"thead": {"table": true},
"tbody": {"table": true},
"tfoot": {"table": true},
"tr": {"table": true, "thead": true, "tbody": true, "tfoot": true},
"th": {"tr": true},
"td": {"tr": true},
"colgroup": {"table": true},
"col": {"table": true, "colgroup": true},
"okr-objective": {"okr": true},
"okr-key-result": {"okr": true, "okr-objective": true},
"okr-progress": {"okr-objective": true, "okr-key-result": true},
"sub-page": {"sub-page-list": true},
}
func shouldAutoClose(openTag, nextTag string) bool {
if strictPhrasingTags[openTag] && layoutOf(nextTag) == layoutBlock {
return true
}
return autoCloseTags[openTag] != nil && autoCloseTags[openTag][nextTag]
}
func normalizeAttributes(rawTag, canonical string, attrs map[string]string) map[string]string {
rules := make(map[string]attributeAliasRule, len(commonAttributeAliases)+4)
for alias, rule := range commonAttributeAliases {
rules[alias] = rule
}
for alias, rule := range tagAttributeAliases[canonical] {
rules[alias] = rule
}
rawKey := strings.ToLower(strings.TrimSpace(rawTag))
for alias, rule := range rawTagAttributeAliases[rawKey] {
rules[alias] = rule
}
aliases := make([]string, 0, len(rules))
for alias := range rules {
aliases = append(aliases, alias)
}
sort.Strings(aliases)
for _, alias := range aliases {
value, exists := attrs[alias]
if !exists {
continue
}
rule := rules[alias]
if rule.transform != nil {
var ok bool
value, ok = rule.transform(value)
if !ok {
continue
}
}
if canonicalValue, exists := attrs[rule.canonical]; !exists || strings.TrimSpace(canonicalValue) == "" {
if attrs == nil {
attrs = map[string]string{}
}
attrs[rule.canonical] = value
}
delete(attrs, alias)
}
if rawKey == "at" {
if attrs == nil {
attrs = map[string]string{}
}
attrs["type"] = "user"
}
return attrs
}
func normalizeWidthRatio(value string) (string, bool) {
trimmed := strings.TrimSuffix(strings.TrimSpace(value), "%")
if trimmed == "" {
return value, false
}
width, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return value, false
}
return strconv.FormatFloat(width/100, 'f', 6, 64), true
}
func requireChatID(value string) (string, bool) {
trimmed := strings.TrimSpace(value)
return trimmed, strings.HasPrefix(trimmed, "oc_")
}

View File

@@ -1,342 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
// This file implements the LarkOpenCLI document text-counting contract.
import (
"regexp"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/width"
)
const chinesePunctuation = ",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥"
const englishPunctuation = `!"#$%&'()*+,-./:;<=>?@[\]^_` + "`" + `{|}~`
var (
urlToken = regexp.MustCompile(`^https?://[!-~]+`)
asciiCompoundToken = regexp.MustCompile(`^[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+`)
)
type lexemeKind uint8
const (
lexemeNone lexemeKind = iota
lexemeEnglish
lexemeNumber
)
type textCounter struct {
stats TextProfile
lexeme lexemeKind
lexemeHasDigit bool
symbolRunLength int
atBoundary bool
}
func newTextCounter() *textCounter {
return &textCounter{atBoundary: true}
}
func (c *textCounter) countSegments(segments []textSegment) TextProfile {
for _, segment := range segments {
c.endUnit()
c.atBoundary = true
switch segment.kind {
case segmentMarker:
c.writeMarker(segment.text)
case segmentCode:
c.writeCode(segment.text)
default:
c.write(segment.text)
}
c.endUnit()
c.atBoundary = true
}
c.endUnit()
return c.stats
}
func (c *textCounter) write(value string) {
for offset := 0; offset < len(value); {
if token := matchASCIICompound(value[offset:]); token != "" {
c.writeASCIICompound(token)
offset += len(token)
continue
}
r, size := utf8.DecodeRuneInString(value[offset:])
if r == '/' && isVisibleHanSeparator(value, offset, size) {
c.endUnit()
c.stats.Breakdown.EnglishPunctuations++
c.stats.Breakdown.SymbolWords++
c.stats.WordCount++
c.stats.CharCount++
c.atBoundary = false
offset += size
continue
}
c.writeRune(r)
offset += size
}
}
func (c *textCounter) writeMarker(value string) {
for _, r := range value {
if unicode.IsSpace(r) {
continue
}
c.endUnit()
c.stats.WordCount++
c.stats.CharCount++
c.atBoundary = false
}
}
func (c *textCounter) writeCode(value string) {
for _, r := range value {
c.writeCodeRune(r)
}
}
func (c *textCounter) writeCodeRune(r rune) {
if unicode.IsSpace(r) {
c.endUnit()
c.atBoundary = true
return
}
if unicode.Is(unicode.Han, r) {
c.endLexeme()
c.endSymbolRun(false)
c.stats.Breakdown.HanChars++
c.stats.WordCount++
c.stats.CharCount++
c.atBoundary = false
return
}
if isASCIILetterRune(r) {
c.endSymbolRun(false)
c.stats.Breakdown.EnglishLetters++
c.stats.CharCount++
if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
c.lexeme = lexemeEnglish
}
c.atBoundary = false
return
}
if isASCIIDigitRune(r) {
c.endSymbolRun(false)
c.stats.Breakdown.Digits++
c.stats.CharCount++
c.atBoundary = false
return
}
if isChinesePunctuation(r) {
c.endLexeme()
c.endSymbolRun(false)
c.stats.Breakdown.ChinesePunctuations++
c.stats.WordCount++
c.stats.CharCount++
c.atBoundary = false
return
}
if isEnglishPunctuation(r) {
keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-')
if !keepsLexeme {
hadLexeme := c.lexeme != lexemeNone
c.endLexeme()
if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
c.symbolRunLength++
}
}
c.stats.Breakdown.EnglishPunctuations++
c.stats.CharCount++
if keepsLexeme {
c.atBoundary = false
}
return
}
if unicode.Is(unicode.Symbol, r) {
c.writeSymbol(r)
return
}
c.endLexeme()
c.endSymbolRun(false)
c.atBoundary = false
}
func (c *textCounter) writeRune(r rune) {
if unicode.IsSpace(r) {
c.endUnit()
c.atBoundary = true
return
}
if unicode.Is(unicode.Han, r) {
c.endLexeme()
c.endSymbolRun(false)
c.stats.Breakdown.HanChars++
c.stats.WordCount++
c.stats.CharCount++
c.atBoundary = false
return
}
if isASCIILetterRune(r) {
c.endSymbolRun(false)
c.stats.Breakdown.EnglishLetters++
c.stats.CharCount++
if c.lexeme == lexemeNone || c.lexeme == lexemeNumber {
c.lexeme = lexemeEnglish
}
c.atBoundary = false
return
}
if isASCIIDigitRune(r) {
c.endSymbolRun(false)
c.stats.Breakdown.Digits++
c.stats.CharCount++
c.lexemeHasDigit = true
if c.lexeme == lexemeNone {
c.lexeme = lexemeNumber
}
c.atBoundary = false
return
}
if isChinesePunctuation(r) {
c.endLexeme()
c.endSymbolRun(false)
c.stats.Breakdown.ChinesePunctuations++
c.stats.WordCount++
c.stats.CharCount++
c.atBoundary = false
return
}
if isEnglishPunctuation(r) {
keepsLexeme := c.lexeme == lexemeEnglish && (r == '\'' || r == '-' || c.lexemeHasDigit && r == '.') ||
c.lexeme == lexemeNumber && (r == '.' || r == ',' || r == '-')
if !keepsLexeme {
hadLexeme := c.lexeme != lexemeNone
c.endLexeme()
if !hadLexeme && (c.symbolRunLength > 0 || c.atBoundary) {
c.symbolRunLength++
}
}
c.stats.Breakdown.EnglishPunctuations++
c.stats.CharCount++
if keepsLexeme {
c.atBoundary = false
}
return
}
if unicode.Is(unicode.Symbol, r) {
c.writeSymbol(r)
return
}
c.endLexeme()
c.endSymbolRun(false)
c.atBoundary = false
}
func matchASCIICompound(value string) string {
if match := urlToken.FindString(value); match != "" {
return match
}
match := asciiCompoundToken.FindString(value)
if match == "" || !strings.ContainsAny(match, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {
return ""
}
return match
}
func (c *textCounter) writeASCIICompound(token string) {
c.endUnit()
c.stats.Breakdown.EnglishWords++
c.stats.WordCount++
for _, r := range token {
switch {
case isASCIILetterRune(r):
c.stats.Breakdown.EnglishLetters++
c.stats.CharCount++
case isASCIIDigitRune(r):
c.stats.Breakdown.Digits++
c.stats.CharCount++
case isEnglishPunctuation(r):
c.stats.Breakdown.EnglishPunctuations++
c.stats.CharCount++
}
}
c.atBoundary = false
}
func (c *textCounter) writeSymbol(r rune) {
c.endLexeme()
c.endSymbolRun(false)
units := utf16Units(r)
c.stats.Breakdown.SymbolWords++
c.stats.Breakdown.SymbolChars += units
c.stats.WordCount++
c.stats.CharCount += units
c.atBoundary = false
}
func (c *textCounter) endUnit() {
c.endLexeme()
c.endSymbolRun(true)
}
func (c *textCounter) endLexeme() {
switch c.lexeme {
case lexemeEnglish:
c.stats.Breakdown.EnglishWords++
c.stats.WordCount++
case lexemeNumber:
c.stats.Breakdown.NumberWords++
c.stats.WordCount++
}
c.lexeme = lexemeNone
c.lexemeHasDigit = false
}
func (c *textCounter) endSymbolRun(countWord bool) {
if c.symbolRunLength > 0 && countWord {
c.stats.Breakdown.SymbolWords++
c.stats.WordCount++
}
if c.symbolRunLength > 0 {
c.atBoundary = false
}
c.symbolRunLength = 0
}
func isVisibleHanSeparator(value string, offset, size int) bool {
if offset == 0 || offset+size >= len(value) {
return false
}
previous, _ := utf8.DecodeLastRuneInString(value[:offset])
next, _ := utf8.DecodeRuneInString(value[offset+size:])
return unicode.Is(unicode.Han, previous) && unicode.Is(unicode.Han, next)
}
func isASCIILetterRune(r rune) bool { return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' }
func isASCIIDigitRune(r rune) bool { return r >= '0' && r <= '9' }
func isChinesePunctuation(r rune) bool {
if strings.ContainsRune(chinesePunctuation, r) {
return true
}
kind := width.LookupRune(r).Kind()
return unicode.Is(unicode.Punct, r) && (kind == width.EastAsianWide || kind == width.EastAsianFullwidth)
}
func isEnglishPunctuation(r rune) bool {
return r < utf8.RuneSelf && strings.ContainsRune(englishPunctuation, r)
}
func utf16Units(r rune) int {
if r > 0xffff {
return 2
}
return 1
}

View File

@@ -1,572 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
import (
"fmt"
"html"
"regexp"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)
const (
MaxInputBytes = 20_000_000
MaxNestingDepth = 1024
)
var forbiddenXMLDeclaration = regexp.MustCompile(`(?i)<!\s*(?:DOCTYPE|ENTITY)\b`)
func validateSource(source string) error {
if len(source) > MaxInputBytes {
return fmt.Errorf("input is too large (%d bytes, limit %d)", len(source), MaxInputBytes)
}
if forbiddenXMLDeclaration.MatchString(source) {
return fmt.Errorf("XML input must not contain DOCTYPE or ENTITY declarations")
}
if !utf8.ValidString(source) {
return fmt.Errorf("input must be valid UTF-8")
}
return nil
}
func parseXML(source string) ([]*Node, error) {
if err := validateSource(source); err != nil {
return nil, err
}
source = strings.TrimPrefix(source, "\uFEFF")
root := newElement("__fragment__", nil)
stack := []*Node{root}
for i := 0; i < len(source); {
lt := strings.IndexByte(source[i:], '<')
if lt < 0 {
if err := validateXMLText(source[i:], i); err != nil {
return nil, err
}
appendText(stack[len(stack)-1], source[i:])
break
}
lt += i
if err := validateXMLText(source[i:lt], i); err != nil {
return nil, err
}
appendText(stack[len(stack)-1], source[i:lt])
token, end, state := scanXMLToken(source, lt)
switch state {
case tokenComment, tokenProcessingInstruction:
i = end
continue
case tokenCDATA:
appendTextValue(stack[len(stack)-1], token.text)
i = end
continue
case tokenInvalid:
return nil, fmt.Errorf("invalid XML token at byte %d", lt)
case tokenIncomplete:
return nil, fmt.Errorf("unterminated XML tag at byte %d", lt)
}
spec, allowed := lookupTag(token.name)
if !allowed {
return nil, fmt.Errorf("unsupported LarkOpenCLI tag <%s> at byte %d", token.name, lt)
}
canonical := spec.canonical
if token.spacingNormalized {
return nil, fmt.Errorf("invalid whitespace in XML tag <%s> at byte %d", token.name, lt)
}
if token.closing {
if isVoidTag(canonical) {
return nil, fmt.Errorf("void tag <%s/> must not have a closing tag", canonical)
}
if len(stack) == 1 {
return nil, fmt.Errorf("unexpected closing tag </%s> at byte %d", canonical, lt)
}
open := stack[len(stack)-1].tag
if open != canonical {
return nil, fmt.Errorf("mismatched closing tag </%s> at byte %d; expected </%s>", canonical, lt, open)
}
stack = stack[:len(stack)-1]
i = end
continue
}
if len(stack) > 1 && shouldAutoClose(stack[len(stack)-1].tag, canonical) {
return nil, fmt.Errorf("invalid <%s> inside <%s> at byte %d", canonical, stack[len(stack)-1].tag, lt)
}
attrs := normalizeAttributes(token.name, canonical, token.attrs)
node := newElement(canonical, attrs)
stack[len(stack)-1].addChild(node)
if !token.selfClosing && !isVoidTag(canonical) {
if len(stack) > MaxNestingDepth {
return nil, fmt.Errorf("XML nesting exceeds limit %d at byte %d", MaxNestingDepth, lt)
}
stack = append(stack, node)
}
i = end
}
if len(stack) > 1 {
return nil, fmt.Errorf("missing closing tag </%s> at end of input", stack[len(stack)-1].tag)
}
normalizeParsedLineBreaks(root.children, false, false)
for _, child := range root.children {
child.parent = nil
}
return root.children, nil
}
// normalizeParsedLineBreaks removes formatting newlines from ordinary XML,
// while source-bearing code/whiteboard blocks keep semantic
// line breaks as explicit <br/> nodes. str_replace pattern/replacement payloads
// retain raw newlines because their string matching semantics depend on them.
func normalizeParsedLineBreaks(nodes []*Node, sourceBlock, stringMutation bool) {
for _, node := range nodes {
if node == nil || node.typ != nodeElement {
continue
}
nextSourceBlock := sourceBlock || node.tag == "code" || node.tag == "whiteboard"
nextStringMutation := stringMutation || node.tag == "str_replace"
preserveRaw := nextStringMutation && (node.tag == "pattern" || node.tag == "replacement")
if node.tag == "code" || node.tag == "whiteboard" {
trimSourceBlockBoundaryNewlines(node.children)
}
children := make([]*Node, 0, len(node.children))
for _, child := range node.children {
if child.typ != nodeText || !strings.ContainsAny(child.text, "\r\n") {
children = append(children, child)
continue
}
switch {
case preserveRaw:
children = append(children, child)
case nextSourceBlock:
for _, replacement := range rawTextWithBreakNodes(child.text) {
replacement.parent = node
children = append(children, replacement)
}
default:
child.text = strings.NewReplacer("\r", "", "\n", "").Replace(child.text)
if child.text != "" {
children = append(children, child)
}
}
}
node.children = children
normalizeParsedLineBreaks(node.children, nextSourceBlock, nextStringMutation)
}
}
func trimSourceBlockBoundaryNewlines(children []*Node) {
for _, child := range children {
if child.typ == nodeText {
child.text = strings.TrimLeft(child.text, "\r\n")
break
}
if child.typ == nodeElement {
break
}
}
for i := len(children) - 1; i >= 0; i-- {
child := children[i]
if child.typ == nodeText {
child.text = strings.TrimRight(child.text, "\r\n")
break
}
if child.typ == nodeElement {
break
}
}
}
func rawTextWithBreakNodes(content string) []*Node {
if content == "" {
return nil
}
var nodes []*Node
start := 0
for i := 0; i < len(content); i++ {
if content[i] != '\n' && content[i] != '\r' {
continue
}
if i > start {
nodes = append(nodes, newText(content[start:i]))
}
if content[i] == '\r' && i+1 < len(content) && content[i+1] == '\n' {
i++
}
nodes = append(nodes, newElement("br", nil))
start = i + 1
}
if start < len(content) {
nodes = append(nodes, newText(content[start:]))
}
return nodes
}
type tokenState uint8
const (
tokenOK tokenState = iota
tokenInvalid
tokenIncomplete
tokenComment
tokenProcessingInstruction
tokenCDATA
)
type xmlToken struct {
name string
attrs map[string]string
text string
closing bool
selfClosing bool
spacingNormalized bool
}
func scanXMLToken(source string, start int) (xmlToken, int, tokenState) {
if strings.HasPrefix(source[start:], "<![CDATA[") {
const marker = "<![CDATA["
contentStart := start + len(marker)
if closeAt := strings.Index(source[contentStart:], "]]>"); closeAt >= 0 {
contentEnd := contentStart + closeAt
return xmlToken{text: source[contentStart:contentEnd]}, contentEnd + len("]]>"), tokenCDATA
}
return xmlToken{}, len(source), tokenIncomplete
}
if strings.HasPrefix(source[start:], "<!--") {
if closeAt := strings.Index(source[start+4:], "-->"); closeAt >= 0 {
if strings.Contains(source[start+4:start+4+closeAt], "--") {
return xmlToken{}, start + 1, tokenInvalid
}
return xmlToken{}, start + 4 + closeAt + 3, tokenComment
}
return xmlToken{}, len(source), tokenIncomplete
}
if strings.HasPrefix(source[start:], "<?") {
if closeAt := strings.Index(source[start+2:], "?>"); closeAt >= 0 {
return xmlToken{}, start + 2 + closeAt + 2, tokenProcessingInstruction
}
return xmlToken{}, len(source), tokenIncomplete
}
quote := byte(0)
end := -1
for i := start + 1; i < len(source); i++ {
switch source[i] {
case '\'', '"':
if quote == 0 {
quote = source[i]
} else if quote == source[i] {
quote = 0
}
case '>':
if quote == 0 {
end = i + 1
i = len(source)
}
case '<':
// A second unquoted '<' cannot belong to the current XML tag.
// Stop here so a long sequence of invalid tag starts is scanned
// once instead of repeatedly searching to a distant '>'.
if quote == 0 {
return xmlToken{}, start + 1, tokenInvalid
}
}
}
if end < 0 {
candidate := strings.TrimSpace(source[start+1:])
if candidate == "" || !isTagNameStart(candidate[0]) && candidate[0] != '/' {
return xmlToken{}, start + 1, tokenInvalid
}
return xmlToken{}, len(source), tokenIncomplete
}
body := source[start+1 : end-1]
if body == "" {
return xmlToken{}, end, tokenInvalid
}
token := xmlToken{}
position := 0
for position < len(body) && isXMLSpace(body[position]) {
position++
}
if position > 0 {
token.spacingNormalized = true
}
if position >= len(body) || body[position] == '!' {
return xmlToken{}, end, tokenInvalid
}
if body[position] == '/' {
token.closing = true
position++
spaceStart := position
for position < len(body) && isXMLSpace(body[position]) {
position++
}
if position > spaceStart {
token.spacingNormalized = true
}
}
if position >= len(body) || !isTagNameStart(body[position]) {
return xmlToken{}, end, tokenInvalid
}
nameStart := position
position++
for position < len(body) && isTagNamePart(body[position]) {
position++
}
token.name = body[nameStart:position]
rawRemainder := body[position:]
remainder := strings.TrimRightFunc(rawRemainder, unicode.IsSpace)
if token.closing {
if strings.TrimSpace(remainder) != "" {
return xmlToken{}, end, tokenInvalid
}
return token, end, tokenOK
}
if strings.HasSuffix(remainder, "/") {
if len(remainder) != len(rawRemainder) {
return xmlToken{}, end, tokenInvalid
}
token.selfClosing = true
remainder = strings.TrimRightFunc(strings.TrimSuffix(remainder, "/"), unicode.IsSpace)
}
trimmedAttrs := strings.TrimLeftFunc(remainder, unicode.IsSpace)
if trimmedAttrs != "" && !isAttributeNameStart(trimmedAttrs[0]) {
return xmlToken{}, end, tokenInvalid
}
var ok bool
token.attrs, ok = parseStrictAttributes(remainder)
if !ok {
return xmlToken{}, end, tokenInvalid
}
return token, end, tokenOK
}
func isXMLSpace(ch byte) bool {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'
}
func isTagNameStart(ch byte) bool {
return ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
}
func isTagNamePart(ch byte) bool {
return isTagNameStart(ch) || ch >= '0' && ch <= '9' || ch == '_' || ch == '-' || ch == '.' || ch == ':'
}
func isAttributeNameStart(ch byte) bool {
return isTagNameStart(ch) || ch == '_' || ch == ':'
}
func parseAttributes(source string) map[string]string {
attrs := map[string]string{}
for i := 0; i < len(source); {
for i < len(source) && unicode.IsSpace(rune(source[i])) {
i++
}
if i >= len(source) {
break
}
start := i
for i < len(source) && isAttributeNameByte(source[i]) {
i++
}
if start == i {
i++
continue
}
name := source[start:i]
for i < len(source) && unicode.IsSpace(rune(source[i])) {
i++
}
value := ""
if i < len(source) && source[i] == '=' {
i++
for i < len(source) && unicode.IsSpace(rune(source[i])) {
i++
}
if i < len(source) && (source[i] == '\'' || source[i] == '"') {
quote := source[i]
i++
start = i
for i < len(source) && source[i] != quote {
i++
}
value = source[start:i]
if i < len(source) {
i++
}
} else {
start = i
for i < len(source) && !unicode.IsSpace(rune(source[i])) {
i++
}
value = source[start:i]
}
}
attrs[name] = html.UnescapeString(value)
}
if len(attrs) == 0 {
return nil
}
return attrs
}
// parseStrictAttributes implements the quoted attribute grammar accepted by
// XML. parseAttributes remains intentionally permissive for the Markdown
// container extension, whose input is Markdown rather than an XML document.
func parseStrictAttributes(source string) (map[string]string, bool) {
attrs := map[string]string{}
for i := 0; i < len(source); {
spaceStart := i
for i < len(source) && isXMLSpace(source[i]) {
i++
}
if i >= len(source) {
break
}
if i == spaceStart || !isAttributeNameStart(source[i]) {
return nil, false
}
nameStart := i
i++
for i < len(source) && isTagNamePart(source[i]) {
i++
}
name := source[nameStart:i]
if _, exists := attrs[name]; exists {
return nil, false
}
for i < len(source) && isXMLSpace(source[i]) {
i++
}
if i >= len(source) || source[i] != '=' {
return nil, false
}
i++
for i < len(source) && isXMLSpace(source[i]) {
i++
}
if i >= len(source) || (source[i] != '\'' && source[i] != '"') {
return nil, false
}
quote := source[i]
i++
valueStart := i
for i < len(source) && source[i] != quote {
if source[i] == '<' {
return nil, false
}
i++
}
if i >= len(source) {
return nil, false
}
rawValue := normalizeXMLAttributeAmpersands(source[valueStart:i])
if invalidXMLEntityAt(rawValue) >= 0 {
return nil, false
}
attrs[name] = html.UnescapeString(rawValue)
i++
}
if len(attrs) == 0 {
return nil, true
}
return attrs, true
}
func isAttributeNameByte(ch byte) bool {
return ch > ' ' && ch != '=' && ch != '/' && ch != '>'
}
func appendText(parent *Node, raw string) {
if parent == nil || raw == "" {
return
}
appendTextValue(parent, html.UnescapeString(raw))
}
func appendTextValue(parent *Node, text string) {
if parent == nil || text == "" {
return
}
if strings.TrimSpace(text) == "" && !preserveSpaceTags[parent.tag] && parent.tag != "whiteboard" {
return
}
if count := len(parent.children); count > 0 && parent.children[count-1].typ == nodeText {
parent.children[count-1].text += text
return
}
parent.addChild(newText(text))
}
func validateXMLText(value string, absoluteOffset int) error {
if offset := strings.Index(value, "]]>"); offset >= 0 {
return fmt.Errorf("invalid ]]> sequence in XML text at byte %d", absoluteOffset+offset)
}
if offset := invalidXMLEntityAt(value); offset >= 0 {
return fmt.Errorf("invalid XML entity at byte %d", absoluteOffset+offset)
}
return nil
}
func invalidXMLEntityAt(value string) int {
for cursor := 0; cursor < len(value); {
relative := strings.IndexByte(value[cursor:], '&')
if relative < 0 {
return -1
}
start := cursor + relative
endRelative := strings.IndexByte(value[start+1:], ';')
if endRelative < 0 {
return start
}
end := start + 1 + endRelative
if !isValidXMLEntity(value[start+1 : end]) {
return start
}
cursor = end + 1
}
return -1
}
func isValidXMLEntity(entity string) bool {
switch entity {
case "amp", "lt", "gt", "quot", "apos":
return true
}
base := 10
digits := ""
switch {
case strings.HasPrefix(entity, "#x"):
base = 16
digits = entity[2:]
case strings.HasPrefix(entity, "#"):
digits = entity[1:]
default:
return false
}
if digits == "" {
return false
}
value, err := strconv.ParseUint(digits, base, 32)
if err != nil {
return false
}
r := rune(value)
return r == '\t' || r == '\n' || r == '\r' ||
r >= 0x20 && r <= 0xD7FF ||
r >= 0xE000 && r <= 0xFFFD ||
r >= 0x10000 && r <= utf8.MaxRune
}

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docxparse
import "strings"
// normalizeXMLAttributeAmpersands escapes bare ampersands in an XML attribute
// value so local parsing matches the server SDK. Complete entity references
// remain untouched for the strict parser to validate.
func normalizeXMLAttributeAmpersands(value string) string {
firstBare := -1
for cursor := 0; cursor < len(value); {
relative := strings.IndexByte(value[cursor:], '&')
if relative < 0 {
break
}
ampersand := cursor + relative
if isBareXMLAttributeAmpersand(value, ampersand) {
firstBare = ampersand
break
}
cursor = ampersand + 1
}
if firstBare < 0 {
return value
}
var out strings.Builder
out.Grow(len(value))
out.WriteString(value[:firstBare])
for i := firstBare; i < len(value); i++ {
if value[i] == '&' && isBareXMLAttributeAmpersand(value, i) {
out.WriteString("&amp;")
continue
}
out.WriteByte(value[i])
}
return out.String()
}
func isBareXMLAttributeAmpersand(value string, start int) bool {
if start+1 >= len(value) {
return true
}
if value[start+1] == '#' {
return false
}
if !isTagNameStart(value[start+1]) && value[start+1] != '_' {
return true
}
for i := start + 2; i < len(value); i++ {
if value[i] == ';' {
return false
}
if !isTagNamePart(value[i]) {
return true
}
}
return true
}

View File

@@ -33,8 +33,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
case "script":
return docsSkillReadCommand + " references/lark-doc-script.md"
default:
return docsSkillReadCommand
}
@@ -54,8 +52,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
case "script":
return "lark-cli docs +script --help"
default:
return "lark-cli docs --help"
}
@@ -68,7 +64,6 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
DocsScript,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveMemberListSpec struct {
Token string
Type string
Fields string
PermType string
}
var driveMemberListTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder",
}
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
var driveMemberListPermTypes = []string{"container", "single_page"}
var driveMemberListURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
}
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveMemberListSpec{}, err
}
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
if err != nil {
return driveMemberListSpec{}, err
}
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
if err != nil {
return driveMemberListSpec{}, err
}
return driveMemberListSpec{
Token: token,
Type: resourceType,
Fields: fields,
PermType: permType,
}, nil
}
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
}
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
if err != nil {
return "", "", err
}
if strings.Contains(raw, "://") {
parsed, parseErr := url.Parse(raw)
if parseErr != nil || parsed.Hostname() == "" {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
}
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
if !ok {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
raw,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return ref.Token, ref.Type, nil
}
if explicitType == "" {
return "", "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token; accepted values: %s",
strings.Join(driveMemberListTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(raw, "--token"); err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return raw, explicitType, nil
}
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
for _, mapping := range driveMemberListURLPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
}
token := path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: mapping.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
if changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
return "", nil
}
parts := strings.Split(raw, ",")
fields := make([]string, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
field := strings.ToLower(strings.TrimSpace(part))
if field == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
}
if field == "*" {
if len(parts) != 1 {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
}
return "*", nil
}
if !driveMemberListFieldAllowed(field) {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for --fields, allowed: %s, *",
strings.TrimSpace(part),
strings.Join(driveMemberListFields, ", "),
).WithParam("--fields")
}
if !seen[field] {
fields = append(fields, field)
seen[field] = true
}
}
return strings.Join(fields, ","), nil
}
func driveMemberListFieldAllowed(field string) bool {
for _, allowed := range driveMemberListFields {
if field == allowed {
return true
}
}
return false
}
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
if err != nil {
return "", err
}
if resourceType != "wiki" && changed {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
}
return permType, nil
}
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
for _, candidate := range allowed {
if strings.EqualFold(value, candidate) {
return candidate, nil
}
}
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid value %q for %s, allowed: %s",
value,
flagName,
strings.Join(allowed, ", "),
).WithParam(flagName)
}
func (s driveMemberListSpec) apiPath() string {
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
}
func (s driveMemberListSpec) params() map[string]interface{} {
params := map[string]interface{}{"type": s.Type}
if s.Fields != "" {
params["fields"] = s.Fields
}
if s.PermType != "" {
params["perm_type"] = s.PermType
}
return params
}
// DriveMemberList lists collaborator/member permissions on a Drive resource.
var DriveMemberList = common.Shortcut{
Service: "drive",
Command: "+member-list",
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
Risk: "read",
Scopes: []string{"docs:permission.member:retrieve"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders.",
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
"--perm-type only applies to wiki nodes.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveMemberListSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("List Drive collaborator/member permissions").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
if err != nil {
return err
}
if items, ok := data["items"].([]interface{}); ok {
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
}
runtime.OutFormat(data, nil, func(w io.Writer) {
renderDriveMemberListPretty(w, data)
})
return nil
},
}
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
items, _ := data["items"].([]interface{})
if len(items) == 0 {
fmt.Fprintln(w, "No Drive members found.")
return
}
for i, raw := range items {
member, _ := raw.(map[string]interface{})
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
fmt.Fprintf(w, " perm_type: %s\n", permType)
}
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
fmt.Fprintf(w, " type: %s\n", memberType)
}
if name := driveMemberListValue(member["name"]); name != "-" {
fmt.Fprintf(w, " name: %s\n", name)
}
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
fmt.Fprintf(w, " avatar: %s\n", avatar)
}
if label, ok := member["external_label"]; ok {
fmt.Fprintf(w, " external_label: %v\n", label)
}
fmt.Fprintln(w)
}
}
func driveMemberListValue(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}

View File

@@ -0,0 +1,426 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +member-list"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("fields", "", "")
cmd.Flags().String("perm-type", "", "")
for name, value := range map[string]string{
"token": token,
"type": docType,
"fields": fields,
"perm-type": permType,
} {
if value == "" {
continue
}
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
}
})
}
}
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "folder",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid value",
},
{
name: "invalid fields",
token: "doxTok",
docType: "docx",
fields: "name,unknown",
wantParam: "--fields",
wantMessage: "invalid value",
},
{
name: "star mixed with fields",
token: "doxTok",
docType: "docx",
fields: "*,name",
wantParam: "--fields",
wantMessage: "cannot be combined",
},
{
name: "perm type rejected for non-wiki",
token: "doxTok",
docType: "docx",
permType: "single_page",
wantParam: "--perm-type",
wantMessage: "only applies when resource type is wiki",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
_, err := readDriveMemberListSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDriveMemberListSpecParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
fields string
permType string
want map[string]interface{}
}{
{
name: "default omits optional params",
token: "doxTok",
docType: "docx",
want: map[string]interface{}{"type": "docx"},
},
{
name: "fields canonicalized and deduplicated",
token: "doxTok",
docType: "docx",
fields: "Name,avatar,name",
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
},
{
name: "wiki accepts perm type",
token: "wikTok",
docType: "WIKI",
fields: "*",
permType: "SINGLE_PAGE",
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
spec, err := readDriveMemberListSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("params = %#v, want %#v", got, tt.want)
}
})
}
}
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "https://example.feishu.cn/drive/folder/fldTok",
"--fields", "*",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
Data struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
}
if len(got.Data.API) != 1 {
t.Fatalf("api count = %d, want 1", len(got.Data.API))
}
api := got.Data.API[0]
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
t.Fatalf("api = %#v", api)
}
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
t.Fatalf("params = %#v", api.Params)
}
if _, ok := api.Params["perm_type"]; ok {
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
}
}
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/doxTok/members",
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"type": "user",
"name": "zhangsan",
"server_future": "preserved",
"external_label": true,
},
},
"server_top_level": "preserved",
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "doxTok",
"--type", "docx",
"--fields", "name,type,external_label",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(capturedQuery, "type=docx") ||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
t.Fatalf("captured query = %q", capturedQuery)
}
data := decodeDriveEnvelope(t, stdout)
if data["server_top_level"] != "preserved" {
t.Fatalf("server_top_level = %#v", data["server_top_level"])
}
for _, key := range []string{"token", "type", "count"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
}
}
items, _ := data["items"].([]interface{})
if len(items) != 1 {
t.Fatalf("items = %#v, want one item", data["items"])
}
item, _ := items[0].(map[string]interface{})
if item["server_future"] != "preserved" || item["external_label"] != true {
t.Fatalf("item future fields not preserved: %#v", item)
}
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
t.Fatalf("stderr = %q, want count log", stderr.String())
}
}
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
}
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
}
}
func TestDriveMemberListPrettyOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/permissions/wikTok/members",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"perm": "view",
"perm_type": "single_page",
"type": "user",
"name": "zhangsan",
},
},
},
},
})
err := mountAndRunDrive(t, DriveMemberList, []string{
"+member-list",
"--token", "wikTok",
"--type", "wiki",
"--perm-type", "single_page",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}

View File

@@ -0,0 +1,241 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type drivePermissionGetSettingSpec struct {
Token string
Type string
}
var drivePermissionGetSettingTypes = []string{
"doc", "sheet", "file", "wiki", "bitable", "docx",
"mindnote", "minutes", "slides", "folder",
}
var drivePermissionGetSettingURLPathToType = []struct {
Prefix string
Type string
}{
{"/drive/folder/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/wiki/", "wiki"},
{"/file/", "file"},
{"/mindnotes/", "mindnote"},
{"/slides/", "slides"},
{"/minutes/", "minutes"},
}
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
rawToken := strings.TrimSpace(runtime.Str("token"))
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
if rawToken == "" {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--token is required",
).WithParam("--token")
}
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid --type %q: allowed values are %s",
explicitType,
strings.Join(drivePermissionGetSettingTypes, ", "),
).WithParam("--type")
}
if strings.Contains(rawToken, "://") {
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
if !ok {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
rawToken,
).WithParam("--token")
}
if explicitType != "" && explicitType != ref.Type {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
explicitType,
ref.Type,
).WithParam("--type")
}
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
}
if explicitType == "" {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type is required when --token is a bare token (allowed: %s)",
strings.Join(drivePermissionGetSettingTypes, ", "),
).WithParam("--type")
}
if err := validate.ResourceName(rawToken, "--token"); err != nil {
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
}
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
}
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Hostname() == "" {
return common.ResourceRef{}, false
}
for _, mapping := range drivePermissionGetSettingURLPathToType {
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
continue
}
token := parsed.Path[len(mapping.Prefix):]
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return common.ResourceRef{}, false
}
return common.ResourceRef{Type: mapping.Type, Token: token}, true
}
return common.ResourceRef{}, false
}
func drivePermissionGetSettingTypeAllowed(docType string) bool {
for _, allowed := range drivePermissionGetSettingTypes {
if docType == allowed {
return true
}
}
return false
}
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
if runtime != nil && runtime.Config != nil {
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
return u
}
}
return common.BuildResourceURL("", s.Type, s.Token)
}
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
return map[string]interface{}{"type": s.Type}
}
func (s drivePermissionGetSettingSpec) apiPath() string {
return drivePermissionPublicV2Path(s.Token)
}
func drivePermissionPublicV2Path(token string) string {
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
}
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
permissionPublic := common.GetMap(data, "permission_public")
if permissionPublic == nil {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"drive permission get response missing data.permission_public",
)
}
return permissionPublic, nil
}
// DrivePermissionGetSetting queries permission_public settings for a Drive
// document, file, wiki node, or folder.
var DrivePermissionGetSetting = common.Shortcut{
Service: "drive",
Command: "+permission-get-setting",
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
Risk: "read",
Scopes: []string{"docs:permission.setting:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
},
Tips: []string{
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDrivePermissionGetSettingSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Get Drive permission settings").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
data, err := runtime.CallAPITyped(
"GET",
spec.apiPath(),
spec.params(),
nil,
)
if err != nil {
return err
}
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
if err != nil {
return err
}
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
if err != nil {
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"encode drive permission settings for pretty output",
).WithCause(err)
}
out := map[string]interface{}{"permission_public": permissionPublic}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "Type: %s\n", spec.Type)
fmt.Fprintf(w, "Token: %s\n", spec.Token)
if url := spec.url(runtime); url != "" {
fmt.Fprintf(w, "URL: %s\n", url)
}
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
})
return nil
},
}

View File

@@ -0,0 +1,438 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
cmd.Flags().String("token", "", "")
cmd.Flags().String("type", "", "")
if token != "" {
if err := cmd.Flags().Set("token", token); err != nil {
t.Fatalf("set --token: %v", err)
}
}
if docType != "" {
if err := cmd.Flags().Set("type", docType); err != nil {
t.Fatalf("set --type: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantTok string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantTok: "doxTok",
wantType: "docx",
},
{
name: "file URL",
token: "https://example.feishu.cn/file/boxTok",
wantTok: "boxTok",
wantType: "file",
},
{
name: "wiki URL",
token: "https://example.feishu.cn/wiki/wikTok",
wantTok: "wikTok",
wantType: "wiki",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantTok: "obTok",
wantType: "minutes",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantTok: "mndTok",
wantType: "mindnote",
},
{
name: "bare folder token",
token: " fldTok ",
docType: " folder ",
wantTok: "fldTok",
wantType: "folder",
},
{
name: "bare file token",
token: "boxTok",
docType: "file",
wantTok: "boxTok",
wantType: "file",
},
{
name: "bare wiki token",
token: "wikTok",
docType: "wiki",
wantTok: "wikTok",
wantType: "wiki",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
spec, err := readDrivePermissionGetSettingSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.Token != tt.wantTok {
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
}
if spec.Type != tt.wantType {
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
}
})
}
}
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantParam string
wantMessage string
}{
{
name: "missing token",
wantParam: "--token",
wantMessage: "--token is required",
},
{
name: "bare token without type",
token: "doxTok",
wantParam: "--type",
wantMessage: "--type is required",
},
{
name: "unsupported URL",
token: "https://example.feishu.cn/calendar/calTok",
wantParam: "--token",
wantMessage: "unsupported --token URL",
},
{
name: "URL type conflict",
token: "https://example.feishu.cn/docx/doxTok",
docType: "sheet",
wantParam: "--type",
wantMessage: "conflicts with URL path type",
},
{
name: "invalid bare token",
token: "../bad",
docType: "folder",
wantParam: "--token",
wantMessage: "--token",
},
{
name: "invalid type",
token: "doxTok",
docType: "comment",
wantParam: "--type",
wantMessage: "invalid --type",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
_, err := readDrivePermissionGetSettingSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr, ok := err.(*errs.ValidationError); ok {
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
} else {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
docType string
wantURL string
wantType string
}{
{
name: "folder URL",
token: "https://example.feishu.cn/drive/folder/fldTok",
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
wantType: "folder",
},
{
name: "bare folder token",
token: "fldTok",
docType: "folder",
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
wantType: "folder",
},
{
name: "docx URL",
token: "https://example.feishu.cn/docx/doxTok",
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
wantType: "docx",
},
{
name: "bare wiki token",
token: "wikTok",
docType: "wiki",
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
wantType: "wiki",
},
{
name: "file URL",
token: "https://example.feishu.cn/file/boxTok",
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
wantType: "file",
},
{
name: "minutes URL",
token: "https://example.feishu.cn/minutes/obTok",
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
wantType: "minutes",
},
{
name: "mindnotes URL",
token: "https://example.feishu.cn/mindnotes/mndTok",
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
wantType: "mindnote",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
out := string(data)
for _, want := range []string{
`"` + tt.wantURL + `"`,
`"GET"`,
`"type":"` + tt.wantType + `"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"folder_token"`) {
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
}
})
}
}
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
for _, key := range []string{"type", "token", "url"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
}
}
permissionPublic, _ := data["permission_public"].(map[string]interface{})
if permissionPublic == nil {
t.Fatalf("permission_public missing in output: %#v", data)
}
for key, want := range map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_field": "preserved",
} {
if permissionPublic[key] != want {
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
}
}
}
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{"unexpected": "response"},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected invalid response error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
}
}
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"server_future_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
"+permission-get-setting",
"--token", "doxTok",
"--type", "docx",
"--format", "pretty",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
"Permission settings:",
`"link_share_entity": "closed"`,
`"server_future_field": "preserved"`,
} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
}
}
}
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
}
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
}
for _, flag := range DrivePermissionGetSetting.Flags {
if flag.Name == "token" && !flag.Required {
t.Fatal("--token must be declared required")
}
}
}

View File

@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveMemberList,
DrivePermissionGetSetting,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+member-list",
"+permission-get-setting",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -17,9 +17,10 @@ import (
)
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -27,22 +28,37 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken.
func sheetMediaParentType(spreadsheetToken string) string {
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "fake_office_abc123",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"office_sheet_file"`) {
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
}
if strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
}
}
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
}
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
// parent_type=office_sheet_file instead of the native sheet_image.
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
dir := t.TempDir()
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
}
reg.Register(stub)
const officeToken = "fake_office_abc123"
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", officeToken,

View File

@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
}
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -63,21 +64,38 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -25,8 +25,9 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets use either a legacy
// prefix or the interleaved "OFL0X" marker and must upload with
// "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
// to end (the Execute path the dry-run tests don't reach), asserting the
// parent_type that actually goes out on the wire is derived from the token: a
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
// (fake_office_-prefixed token) as office_sheet_file.
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
func TestUploadSheetImage_ParentType(t *testing.T) {
cases := []struct {
name string
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -43,7 +43,7 @@ var SlidesScreenshot = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
@@ -55,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -71,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -96,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -146,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -198,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -217,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {

View File

@@ -185,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
}
}
func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
},
{
"slide_id": "slide_2",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1,slide_2",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
}
path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
if _, err := os.ReadFile(path1); err != nil {
t.Fatalf("read first CSV slide screenshot: %v", err)
}
path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
if _, err := os.ReadFile(path2); err != nil {
t.Fatalf("read second CSV slide screenshot: %v", err)
}
}
func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
},
{
"slide_id": "slide_2",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
},
},
},
},
}
reg.Register(stub)
// CSV with a duplicate and blank segments should normalize the same way
// normalizeSlideIDs already does for repeated --slide-id flags.
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1, slide_2,slide_1,",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
}
}
func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %v, want typed validation error", err)
}
if problem.Hint != "request at most 10 pages at a time" {
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
}
}
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
@@ -387,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
}
}
func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
// Exercises the --slide-number-only side of the --content conflict check
// (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
// --slide-id side of that same `||` condition).
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--slide-number", "1",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
t.Fatalf("error = %v, want content/slide selector conflict", err)
}
}
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))

View File

@@ -57,12 +57,12 @@ metadata:
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue上传走本地文件下载/删除按 file token 或字段定位 |
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md);其余配置先 get 现状,再按返回结构更新 |
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
@@ -116,6 +116,7 @@ metadata:
## 表单与视图细节
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-questions-update` 是题目配置全量覆盖,不是 patch未传字段会回落默认值传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`
- `+view-set-filter` 是唯一保留的 view referencesort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
@@ -146,13 +147,14 @@ metadata:
## 保留 Reference
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT`+data-query``filters` 结构是独立对象 DSL不使用公共 tuple filter 协议
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT不适用于 `+data-query`
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)workflow 入口与 steps JSON SSOT

View File

@@ -0,0 +1,179 @@
# Base Filter 条件结构(公共协议)
Filter 是一组「字段/操作符/值」条件的组合,用 `logic``and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构本文件是其公共协议SSOT
## 0. 适用范围
本协议只适用于以下场景:
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
## 1. 顶层结构
- 必须是 JSON 对象。
- 顶层结构是 `{logic?, conditions?}`
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`
- `conditions` 默认空数组。
- 每条条件写成 tuple`[field, operator, value?]`
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]``[field, "non_empty"]`
```json
{
"logic": "and",
"conditions": [
["状态", "intersects", ["Doing"]],
["负责人", "intersects", [{ "id": "ou_xxx" }]],
["截止时间", "empty"]
]
}
```
清空写法:
```json
{
"conditions": []
}
```
## 2. operator
可用 operator
- `==`
- `!=`
- `>`
- `>=`
- `<`
- `<=`
- `intersects`
- `disjoint`
- `empty`
- `non_empty`
## 3. value 写法
value 类型取决于条件引用对象(字段 / 题目)的类型。
### `text`
用字符串:
```json
["标题", "intersects", "发布"]
```
### `location`
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
```json
["位置", "intersects", "深圳"]
```
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
### `number` / `auto_number`
用数字:
```json
["工时", ">=", 3.5]
```
### `select`
用选项名数组:
```json
["状态", "intersects", ["Doing", "Blocked"]]
```
### `user` / `created_by` / `updated_by`
用对象数组:
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
```json
["负责人", "intersects", [{ "id": "ou_xxx" }]]
```
### `group_chat`
用对象数组:
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
```json
["负责群", "intersects", [{ "id": "oc_xxx" }]]
```
### `link`
用记录 id 对象数组:
```json
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
```
### `checkbox`
用布尔值:
```json
["完成", "==", true]
```
### `datetime` / `created_at` / `updated_at`
用相对时间关键字或 `ExactDate(...)`
```json
["截止时间", "==", "ExactDate(2026-01-01)"]
```
```json
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
```
```json
["截止时间", "==", "Today"]
```
可用关键字:
- `Today`
- `Yesterday`
- `Tomorrow`
### `formula` / `lookup`
- 筛选值类型由字段计算结果类型动态决定。
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
- 如果报错,再按错误提示把 `value` 改成对应类型。
字符串示例:
```json
["风险说明", "intersects", "高风险"]
```
数字示例:
```json
["汇总分", ">=", 80]
```
## 4. 易错点
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`
- `user` / `group_chat` / `link` 不要写成单个标量。
- `empty` / `non_empty` 不要硬塞无意义的 value。
- 日期条件稳定写法用 `ExactDate(...)``Today` / `Yesterday` / `Tomorrow`
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
## 5. 参考
- [lookup-field-guide.md](lookup-field-guide.md)

View File

@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[
{"type":"text","title":"您的姓名是?","required":true},
{"type":"text","title":"您的联系方式是?","required":false}
]'
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
# 添加单选题(带选项)
lark-cli base +form-questions-create \
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
# 添加带显隐条件visible_rule的问题当「是否需要发票」选择「是」时才显示「发票抬头」
lark-cli base +form-questions-create \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
```
## 参数
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效bool |
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
| `style` | 否 | 字段样式配置(见下方说明) |
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
### `style` 字段说明
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd``yyyy/MM/dd HH:mm``MM-dd``MM/dd/yyyy``dd/MM/yyyy` |
### `visible_rule` 显隐条件
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
- 引用的题目必须真实存在,否则会报错。
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null``conditions` 为空数组。
```json
{
"logic": "and",
"conditions": [
["是否需要发票", "==", "是"],
["报销金额", ">=", 1000]
]
}
```
详细的 `visible_rule` 结构顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
## 输出格式
返回创建成功的问题列表:
@@ -115,4 +144,5 @@ lark-cli base +form-questions-create \
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -2,40 +2,60 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
批量更新多维表格表单/问卷中的问题(标题、描述、是否必填)。
批量更新多维表格表单/问卷中的问题配置(标题、描述、是否必填、显隐条件等)。
> [!CAUTION]
> `+form-questions-update` 是**题目配置全量覆盖**,不是 patch。对每个传入的题目未携带的属性会回落为默认值显式传空字符串 / `null` / 空数组会直接写入空或清空;如果要保留现有属性,必须先用 `+form-questions-list` 查出现状,再把要保留的字段一起带回 `--questions`。
## 命令
```bash
# 更新一个问题的标题
lark-cli base +form-questions-update \
# 先读取现有题目配置,作为 read-modify-write 的基线
lark-cli base +form-questions-list \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"id":"q_001","title":"您的真实姓名是?"}]'
--form-id <form_id>
# 同时更新个问题
# 更新个问题的标题,同时带回要保留的 required / description / visible_rule 等字段
lark-cli base +form-questions-update \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[
{"id":"q_001","title":"姓名(必填)","required":true},
{"id":"q_002","title":"联系方式","required":false}
]'
--questions '[{"id":"q_001","title":"您的真实姓名是?","description":"请填写真实姓名","required":true,"visible_rule":null}]'
# 同时更新多个问题;每个对象都应是该题目的目标完整配置
lark-cli base +form-questions-update \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"id":"q_001","title":"姓名(必填)","required":true},{"id":"q_002","title":"联系方式","required":false}]'
# 更新问题描述(纯文本)
# 更新问题描述(纯文本),同时带回要保留的 title / required / visible_rule
lark-cli base +form-questions-update \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"id":"q_001","description":"请填写您的真实姓名"}]'
# 更新问题描述(含链接)
--questions '[{"id":"q_001","title":"您的姓名","description":"请填写您的真实姓名","required":true,"visible_rule":null}]'
# 更新问题描述(含链接),同时带回要保留的 title / required / visible_rule
lark-cli base +form-questions-update \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"id":"q_001","description":"更多说明请参考[帮助文档](https://example.com/help)"}]'
--questions '[{"id":"q_001","title":"反馈建议","description":"更多说明请参考[帮助文档](https://example.com/help)","required":false,"visible_rule":null}]'
# 更新题目显隐条件visible_rule同时带回要保留的 title / description / required
lark-cli base +form-questions-update \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]'
# 清空题目显隐条件(使题目始终显示),同时带回要保留的 title / description / required
lark-cli base +form-questions-update \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":null}]'
```
## 参数
@@ -52,15 +72,46 @@ lark-cli base +form-questions-update \
## `--questions` 格式
每个问题对象必须包含 `id`,其余字段按需传入:
每个问题对象必须包含 `id`。注意:对象不是增量 patch而是该题目的目标完整配置未携带字段会按服务端默认值重建。
| 字段 | 必填 | 说明 |
|------|------|------|
| `id` | **是** | 问题 IDfield_id不可修改 |
| `title` | 否 | 新的问题标题 |
| `description` | 否 | 新的问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)` |
| `required` | 否 | 是否必填 |
| `option_display_mode` | 否 | 选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向 |
| `title` | 否 | 目标问题标题;省略会回落为字段名,传空字符串会写入空标题(若服务端允许) |
| `description` | 否 | 目标问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`;省略或传空字符串都会清空描述 |
| `required` | 否 | 目标是否必填;省略会回落为 `false` |
| `option_display_mode` | 否 | 目标选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向;省略会回落默认展示方式 |
| `visible_rule` | 否 | 目标题目显隐条件;传完整 `{logic, conditions}` 对象覆盖,传 `null` 或省略都会清空(见下方说明) |
## 全量覆盖语义
- 先执行 `+form-questions-list`,读取被更新题目的当前 `id``title``description``required``option_display_mode``visible_rule`
- 构造 `--questions` 时,只改用户明确要求变化的字段;所有仍要保留的字段必须按当前值一并传回。
- 不要用“只传要改的字段”的方式更新题目。比如只传 `{"id":"q_002","title":"新标题"}` 会让 `description` 清空、`required` 回落为 `false``visible_rule` 清空。
- 用户明确要求清空时才传空值:`description:""` 清空描述,`visible_rule:null` 清空显隐条件,`conditions:[]` 也表示无条件显示。
### `visible_rule` 显隐条件
> **仅当用户明确要求为题目设置或修改显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
`visible_rule` 控制题目显示/隐藏,**结构与视图筛选 `filter` 完全一致**`{logic?, conditions?}`),共用同一套公共协议。
- `conditions` 中的 `field` 引用**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID
- 更新时按表单中题目的**实际顺序**判定,只能引用排在当前题目之前的题目;不支持循环引用。
- 更新 `visible_rule` 需传**完整**的 `{logic, conditions}` 对象(整体覆盖);要保留现有显隐条件就必须把当前 `visible_rule` 原样带回;传 `null`、省略 `visible_rule` 或传空 `conditions` 都会使题目始终显示。
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null``conditions` 为空数组。
```json
{
"logic": "and",
"conditions": [
["q_001", "==", "是"],
["q_003", ">=", 1000]
]
}
```
详细的 `visible_rule` 结构顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
## 输出格式
@@ -82,11 +133,13 @@ lark-cli base +form-questions-update \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1. 先用 `+form-questions-list` 获取现有问题及其 `id`
2. 构造包含 `id` 的更新数组
3. 执行命令并报告更新结果
1. 先用 `+form-questions-list` 获取现有问题及其 `id` 和完整配置。
2. 以现有配置为基线,只修改用户明确要求变化的字段;要保留的字段必须原样带回。
3. 构造包含 `id` 和目标完整配置的更新数组。
4. 执行命令并报告更新结果。
## 参考
- [lark-base](../SKILL.md) — 多维表格全部命令
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -4,142 +4,13 @@
更新视图筛选配置。
## 1. 顶层规则
## 1. filter 结构
`--json` 就是一个 filter 条件对象,结构见公共协议 SSOT [lark-base-filter-condition.md](lark-base-filter-condition.md),即 `{logic?, conditions?}`。此处 `conditions` 中的 `field` 引用**数据表字段名或字段 id**。
- `--json` 必须是 JSON 对象。
- 顶层结构是 `{logic?, conditions?}`
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`
- `conditions` 默认空数组。
- 每条条件写成 tuple`[field, operator, value?]`
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]``[field, "non_empty"]`
- 支持 `filter` 的视图类型:`grid``kanban``gallery``calendar``gantt`
## 2. operator
可用 operator
- `==`
- `!=`
- `>`
- `>=`
- `<`
- `<=`
- `intersects`
- `disjoint`
- `empty`
- `non_empty`
## 3. value 写法
### `text`
用字符串:
```json
["标题", "intersects", "发布"]
```
### `location`
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
```json
["位置", "intersects", "深圳"]
```
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
### `number` / `auto_number`
用数字:
```json
["工时", ">=", 3.5]
```
### `select`
用选项名数组:
```json
["状态", "intersects", ["Doing", "Blocked"]]
```
### `user` / `created_by` / `updated_by`
用对象数组:
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
```json
["负责人", "intersects", [{ "id": "ou_xxx" }]]
```
### `group_chat`
用对象数组:
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
```json
["负责群", "intersects", [{ "id": "oc_xxx" }]]
```
### `link`
用记录 id 对象数组:
```json
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
```
### `checkbox`
用布尔值:
```json
["完成", "==", true]
```
### `datetime` / `created_at` / `updated_at`
用相对时间关键字或 `ExactDate(...)`
```json
["截止时间", "==", "ExactDate(2026-01-01)"]
```
```json
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
```
```json
["截止时间", "==", "Today"]
```
可用关键字:
- `Today`
- `Yesterday`
- `Tomorrow`
### `formula` / `lookup`
- 筛选值类型由字段计算结果类型动态决定。
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
- 如果报错,再按错误提示把 `value` 改成对应类型。
字符串示例:
```json
["风险说明", "intersects", "高风险"]
```
数字示例:
```json
["汇总分", ">=", 80]
```
## 4. 推荐命令
## 2. 推荐命令
```bash
lark-cli base +view-set-filter \
@@ -149,7 +20,7 @@ lark-cli base +view-set-filter \
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
```
## 5. JSON 写法
## 3. JSON 写法
```json
{
@@ -170,14 +41,16 @@ lark-cli base +view-set-filter \
}
```
## 6. 使用建议
完整的 operator 列表与各字段类型的 value 写法(`text` / `number` / `select` / `user` / `datetime` / `formula` / `lookup` 等),见 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
## 4. 使用建议
- 先读取当前筛选配置,理解现有 `logic``conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
- 优先传字段 id不要依赖字段名。
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
- 需要清空全部筛选时,直接传 `{"conditions":[]}`
## 7. 易错点
## 5. 易错点
- 本 tuple DSL 由 `+view-set-filter``+record-list` / `+record-search``--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
@@ -186,6 +59,7 @@ lark-cli base +view-set-filter \
- 日期条件稳定写法用 `ExactDate(...)``Today` / `Yesterday` / `Tomorrow`
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
## 8. 参考
## 6. 参考
- [lark-base-filter-condition.md](lark-base-filter-condition.md)filter/visible_rule 条件结构公共协议 SSOT
- [lookup-field-guide.md](lookup-field-guide.md)

View File

@@ -16,14 +16,16 @@ metadata:
## 身份
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
按**日程归属**选身份:
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
```bash
# BAD — bot 身份查用户日程,返回空列表
lark-cli calendar +agenda --as bot
# GOOD — user 身份查日程
# 用户本人日程 → user
lark-cli calendar +agenda --as user
# bot 自建或参与的日程 → bot
lark-cli calendar +agenda --as bot
```
## Shortcuts
@@ -48,6 +50,8 @@ lark-cli calendar +agenda --as user
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
```
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
### `+search-event` — 按关键词、时间范围和参会人搜索日程
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`
@@ -186,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
lark-cli im +chat-search --query <query> --as user
```
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
## 不在本 skill 范围
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)

View File

@@ -32,19 +32,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
| `--start <time>` | 是 | 开始时间ISO 8601`2026-03-12T14:00+08:00` |
| `--end <time>` | 是 | 结束时间ISO 8601 |
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)

View File

@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 |
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |
## 规则
- 构造 `--attendee-ids` 前,先剔除 bot 参会人bot 不占席位、无偏好,不应参与会议室推荐。
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。

View File

@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
```
规则:
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
- 参与人含**群组**:无需展开群组成员查询忙闲
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**

View File

@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
| ------------------------------- | ----- | ------------------------------------------------------------------- |
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式默认**当前时间** |
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |

View File

@@ -43,7 +43,7 @@ lark-cli calendar +update \
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;传空字符串,会把描述清空 |
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径``-`stdin读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*` |
| `--start <time>` | 否 | 新开始时间ISO 8601`2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
| `--end <time>` | 否 | 新结束时间ISO 8601。更新日程时间时必须同时传 `--start` |
| `--rrule <rrule>` | 否 | 新重复规则RFC5545。**不要使用 COUNT如需限制次数推算后转为 UNTIL** |
@@ -58,9 +58,12 @@ lark-cli calendar +update \
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
-`--summary``--description`CLI 以“是否显式传入该 flag”判断是否更新而不是以“值是否为空”判断如果显式传入空字符串会把对应字段清空。
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**``*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`
- 只想增删参会人或会议室时,不需要同时传 `--summary``--start``--end` 等日程字段。
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- bot 可作为合法参会人添加,无需剔除。
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -1,6 +1,7 @@
---
name: lark-doc
description: "飞书云文档Docx / Wiki内容操作读取、创建、编辑文档插入或下载图片附件以及操作思维笔记。用户提供文档 URL/token包括 doubao.com 的 /docx/、/wiki/)时使用;按 URL 路径/token 而非域名路由。文档内嵌资源按读取参考中的统一规则分流。文档评论走 lark-drive表格或 Base 内部数据操作不在本 skill。"
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
metadata:
requires:
bins: ["lark-cli"]
@@ -9,41 +10,75 @@ metadata:
# docs
## 场景与 Shortcut 路由
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
**CRITICAL先判断场景再读取该场景的参考文件不要在任务开始时一次性读取全部参考文件。每个文件只在首次进入对应阶段时读取一次。**
```bash
# 常用示例
lark-cli docs +fetch --doc "文档URL或token若 URL 存在 #share-... 锚点,优先使用锚点方式读取,不要全文拉取"
lark-cli docs +create --content '<title>标题</title><p>内容</p>'
lark-cli docs +update --doc "文档URL或token" --command append --content '<p>内容</p>'
```
**身份:文档操作默认使用 `--as user`**
## 前置条件 — 执行操作前必读
优先使用 `lark-cli docs +<verb>` Shortcut思维笔记使用独立的 `mindnotes` 命令。
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`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-style.md`](references/style/lark-doc-style.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)
### 文档正文
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
- **读取 / 摘要 — [`+fetch`](references/lark-doc-fetch.md)**:先读参考再获取文档。只读或摘要默认用 `simple`;更新前定位用局部 `with-ids`;保真改写才读 `full`;带 `#share-...` 的选区链接按原 URL 传入。
- **从零创作 — [`创建工作流`](references/lark-doc-create-workflow.md) → [`+create`](references/lark-doc-create.md)**:先完整执行创建工作流,**简单任务不是跳过的理由**;通过 Publish Gate 后再创建文档
- **导入 / 空文档 — [`+create`](references/lark-doc-create.md)**:仅创建空文档或原样导入用户提供的完整内容时,跳过创建工作流
- **编辑 / block 直达链接 — [`+update`](references/lark-doc-update.md)**:语义改写、润色、重组、补写或排版时先完整读取参考,按推荐流程 fetch 后局部更新;明确旧文本 → 新文本可直接 `str_replace`,但写后必须 fetch 验证;每次更新后重新获取最新 block IDblock 直达链接也按该参考生成。
> **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML
> - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换
### 辅助能力
## 快速决策
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型Mermaid 可由主 Agent 直接插入SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
- 文档内容中出现嵌入的 `<sheet>``<bitable>``<cite file-type="sheets|bitable">` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身
- **临时文件、解析与统计 — [`+script`](references/lark-doc-script.md)**:创建名称唯一的临时 XML直接解析文档 URL / token 或本地 XML / Markdown、将 Markdown 转为 XML或统计文档总字数 / 总字符数。
- **历史版本 — [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md)**:查询、回滚文档历史版本或检查回滚任务状态。
| 标签 / 属性 | 提取字段 | 切到技能 |
|-|-|-|
| `<sheet token="..." sheet-id="...">` | `token` -> spreadsheet_token, `sheet-id` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<bitable token="..." table-id="...">` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) |
| `<cite type="doc" file-type="sheets" token="..." sheet-id="...">` | 同 `<sheet>` | [`lark-sheets`](../lark-sheets/SKILL.md) |
| `<cite type="doc" file-type="bitable" token="..." table-id="...">` | 同 `<bitable>` | [`lark-base`](../lark-base/SKILL.md) |
| `<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` 读取 src-token 文档,定位 block |
### 资源、画板与思维笔记
## Shortcuts推荐优先使用
- **插入本地素材 — [`+media-insert`](references/lark-doc-media-insert.md)**:在文末插入本地图片或文件
- **预览素材 — [`+media-preview`](references/lark-doc-media-preview.md)**:预览文档中的图片、附件或素材。
- **下载素材 — [`+media-download`](references/lark-doc-media-download.md)**:下载文档中的图片、附件、素材或画板缩略图。
- **Docx 封面 — [`+resource-download` / `+resource-update` / `+resource-delete`](references/lark-doc-resource-cover.md)**:下载、更新或删除 Docx 封面。
- **画板 — [`画板工作流`](references/lark-doc-whiteboard.md)**:创建或更新画板时先读取工作流;更新已有画板必须复用现有 token禁止新建空白画板底层写入优先使用 [`whiteboard +update`](../lark-whiteboard/references/lark-whiteboard-update.md)`docs +whiteboard-update` 仅为别名。
- **思维笔记 — `mindnotes`**:已有思维笔记走 [`思维笔记链路`](references/lark-doc-mindnote.md);新建思维笔记走 [`lark-doc-whiteboard`](references/lark-doc-whiteboard.md)。
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用
### 认证与 Scope
先执行 `docs` / `mindnotes` Shortcut不预读 [`lark-shared`](../lark-shared/SKILL.md) 或预跑 `auth status --verify`仅遇到未认证、token / 身份或 scope 错误时读取该 Skill修复后重试。认证、身份或 scope 管理请求则直接使用该 Skill。
| 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 / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+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 范围
- **Drive 文件级操作**:找文档、导入导出、云空间文件上传 / 下载 / 权限管理 → [`lark-drive`](../lark-drive/SKILL.md)。复制文档、创建副本或另存为副本时,按其指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`
- **文档评论**:添加、查看、回复评论或增删 reaction → [`lark-drive`](../lark-drive/SKILL.md)
- **文档内嵌资源下钻**:处理不在本 Skill 范围的内嵌资源(如电子表格或 Base 内部数据)时,统一读取 [`lark-doc-fetch.md`](references/lark-doc-fetch.md#处理文档内嵌资源) 的「处理文档内嵌资源」,提取 token / ID 后按该节切到对应 Skill 或命令;根 Skill 不重复维护标签分发表。
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)

View File

@@ -1,30 +0,0 @@
# Genre Contract: Business Analysis / 商业分析 (`report.business_analysis`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;结论前置、具体、条件化;模型只用于改变比较或暴露约束,不用管理黑话代替判断 |
| 内容逻辑 | 围绕一个具体决策,比较现状 / 不行动与真实替代项;用统一目标和口径评价价值、全周期成本、风险、约束与可实施性,给出推荐、暂缓或验证门及翻转条件 |
| 事实 / 边界 | 事实、估算、假设、未知和外部依赖分开;数字标来源、时点、单位、口径和置信范围;利益相关方、不可货币化影响和权限边界显著;分析建议不等于批准或承诺 |
| 错误 | 为预选方案找论据;无现状基准或真实替代项;口径不一却排名;单一 ROI / BCR / 评分替代平衡判断;套 SWOT估算冒充事实忽略全周期成本、依赖或分配影响未获批写成已承诺建议不回链证据 |
## 适用与消歧
比较投资、资源、市场、产品、经营或供应选项并支持判断,但正文不要求具名决策者作出选择 / 批准,也不形成授权、资源拨付或执行承诺入口。`商业``市场分析``SWOT`单独只用于召回;回答研究问题走 [`research-report.md`](research-report.md),纯指标解读走 [`data-report.md`](data-report.md),命中上述 ask / 授权入口时走 Workplace Proposal接口、不变量和实现取舍为主走 Technical RFC。
## 子类型
投资 / 资源配置build-buy-partner 或 vendor市场进入 / 扩张;产品 / 组合优先级;经营模式 / 流程;定价 / 商业模式;高不确定性的试点或阶段门。分析深度随金额、复杂度、不可逆性、影响范围和风险提高。
## 证据与方法
- 定义问题、目标、成功标准、范围、约束、决策 owner / 时点和现状 / 不行动基准;记录选项生成与排除理由。
- 对每个可行选项用相同维度比较收益、全生命周期成本、时间、能力 / 依赖、风险、受影响方、不可货币化影响和可逆性。
- 现状数据与预测分开;按需说明币种、价格时点、折现和估算方法。不得从官网标价推断销量、收入或份额。
- 对可能翻转结论的假设做范围、情景或敏感性分析,并给 switching value、决策门或验证信号评分模型须解释权重和证据不能只报总分。
- 缺目标、成功标准、基准或可行选项时只产出 decision frame / options discovery关键估算用 `[成本区间待核]` 和验证计划,可能翻转结论且无法界定时标记 `blocked`
## 结构与高质量写法
推荐与条件 → case for change / 目标 / 现状基准 → 选项生成、排除理由与同口径比较 → 关键假设、风险、情景与翻转条件 → 建议为何优于替代 → 阶段门、监测 / 学习计划与未决条件。把现状当真实选项,用区间和场景替代伪精确单点,显著说明谁获益、谁承担成本,以及什么新证据会改变建议。

View File

@@ -1,32 +0,0 @@
# Genre Contract: Data Report / 数据报告 (`report.data_report`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;准确、可复算、少形容词;标题表达发现、对象和时点,并保留不确定性 |
| 内容逻辑 | 先建立指标契约和可比基线,再回答发生了什么、为何重要、还不能断言什么;观测、解释假设与行动条件分开,限制紧邻相关结论 |
| 事实 / 边界 | 核心指标标定义、单位、分子分母、总体 / 分群、时间窗、来源 / 版本、更新时间和修订状态;比较须同口径,估计须披露可得不确定性,敏感小群体须汇总、抑制或限制访问;数据图须标轴、单位、分母、时点和来源,并提供文字等价信息 |
| 错误 | 只列数字;隐藏分母或口径变化;不可比数据排名;选择性窗口 / 分群;相关性当因果;图轴、单位或来源缺失;统计显著冒充效应大小或业务胜出;伪精确;限制藏在附录 |
## 适用与消歧
解读已定义指标、趋势、分布、漏斗、监控、估计或实验观察值。`有数据``有数字``分析一下`单独不决定路由;研究问题、抽样和可推广性为主走 [`research-report.md`](research-report.md),比较商业选项走 [`business-analysis.md`](business-analysis.md),组织状态、偏差和下一步走 Workplace 周期报告。
## 子类型
- KPI / 经营表现与趋势分群、cohort 与分布;漏斗 / 路径与监控异常。
- A/B 或实验 readout设计和推断不足时只能报告观察值不宣布因果胜出。
- 预测、估计、修订或统计简报;须标模型 / 假设、适用期和修订状态。
## 证据与方法
- 保留可复算的基数、过滤、聚合、估计区间和质量说明;比较前核对定义、总体、时间窗、分母和处理方法。
- 按误解风险同时给绝对值、绝对变化、相对变化和长期基线;不用多余小数位制造精确感。
- 覆盖、缺失、偏差、口径变化和修订若会改变解释,须与对应发现同处,并说明可能方向、规模和影响。
- 描述性差异不得写成因果;解释标为待验证假设。统计显著性不等于效应大小、实际重要性或完整决策依据。
- 缺定义、分母、时间或来源时使用 `[指标定义待核]``[分母待核]`,对应值不得进入结论;不可比数据分开展示。核心决策依赖的质量缺口无法关闭时标记 `blocked`
## 结构与高质量写法
关键发现与决策限制 → 指标契约 / 数据质量 → 总览与基线 → 必要分维、分布和反例 → 可支持的解释与待验证假设 → 条件式行动 / 验证门 → 方法、修订和来源。每段按“观测 → 基线 / 背景 → 限制 → 含义”推进;复杂图同时给出文字结论和必要精确值,任何视觉不得成为唯一证据。

View File

@@ -1,38 +0,0 @@
# Genre Contract: Email / 邮件 (`platform.email`)
## 核心定位(硬约束)
- 交付物是可复制到邮件客户端的邮件成稿,不代表已发送,也不执行收件人查询、邮件发送、草稿箱或邮箱管理;实际邮件操作切到 `lark-mail`
- `presentation_mode` 默认使用 `formal`;仅在用户明确要求其他表达方式且不违反组织规范或所选 content contract 时调整。全文禁止使用 emoji包括主题、正文、列表标记、组件图标和装饰符号。
- 默认只使用短段落、列表和普通链接等基础结构。只有已由用户说明、平台文档或可信配置确认目标平台完整支持飞书富文本时,才允许使用 `rich` 或 rich block不得根据“邮件”“HTML 邮件”、飞书文档承载或平台名称自行推断支持。
- 一封邮件只承担一个主要沟通任务;主题、首段、正文和行动请求围绕同一目的。不得编造发件人身份、收件人关系、事实、权限、承诺、截止时间、附件或已完成动作;缺失但必需的信息使用清楚的占位符。
- 不使用封面或目录。即使已确认平台能力,表格、图片、`callout`、画板及其他 rich block 也只能在信息确有需要时使用,并确保复制、投递和接收后的语义完整。
## 适用与消歧
用户明确要“写邮件、邮件成稿、邮件草稿、邮件措辞、email、e-mail”或要求起草回复、跟进、通知、邀约、外联邮件时使用内容保存在哪里不影响本合同生效。
查看、搜索、发送、回复或管理邮箱中的真实邮件属于 `lark-mail` 操作;邮件系统说明、邮件数据分析、营销方案或把邮件作为信息来源时不触发本合同。若任务既要成稿又要实际发送,先按本合同形成并确认内容,再切到 `lark-mail` 执行发送。
## 邮件主任务
| 主任务 | 内容脊柱 |
|-|-|
| 请求 / 决策 | 目的或结论 → 必要背景 → 明确请求 / 选项 → 期望时间或下一步 |
| 通知 / 同步 | 关键变化 → 影响范围 → 接收方需知 / 需做 → 时间点与联系入口 |
| 回复 / 跟进 | 对应的前情 → 新信息或直接答复 → 未决事项 → 下一步 |
| 邀约 / 外联 | 联系缘由 → 与收件人的相关性 → 具体提议 → 低成本回应方式 |
| 致歉 / 问题沟通 | 承认影响 → 已确认事实 → 补救动作 → 后续安排与边界 |
## 成稿要求
- 成稿先给主题,再给正文;只有用户要求或材料明确时才列出收件人、抄送人等信封字段。主题准确表达对象、事项或所需行动,不使用标题党、空泛寒暄或无信息量的“重要通知”。
- 称呼依据已知关系和语境选择;关系不明时使用稳妥中性的称呼或显式占位符,不擅自套用亲密、职级或性别称谓。
- 首段尽快说明来意、结论或与既有线程的关系。背景只保留收件人理解、判断或行动所需的信息,不把完整报告、会议纪要或思考过程原样搬入邮件。
- 行动请求写清需要谁在何时以何种方式完成什么;材料没有给出负责人或时间时,不自行补造。多个并列事项使用列表,优先让收件人能直接逐项回应。
- 提及链接、附件或引用材料时说明其用途;未实际提供或上传的材料写成待补占位符,不声称“见附件”。回复和跟进邮件只补充新信息,不机械复述整个线程。
- 结尾与邮件目的匹配:请求类明确回应方式,通知类说明无需动作或下一节点,外联类保留易于拒绝或调整的空间。署名仅使用已知身份;身份不明时使用占位符,不虚构姓名、团队或联系方式。
## 交付前检查
确认收件人能从主题和首段判断“为什么收到、需要知道或做什么”,事实、责任人、时间和附件状态均有依据,正文没有无关铺垫或重复,语气符合关系与风险,全文无 emoji若使用 rich block已有目标平台支持飞书富文本的确认依据复制到邮件客户端后仍清晰可读且未把“成稿”误写成“已发送”。

View File

@@ -1,27 +0,0 @@
# Genre Contract: Execution Plan / 执行计划 (`workplace.execution_plan`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | `normal`;以交付和判断为单位,具体、紧凑、可推进;计划可信度来自依赖、产能和验收闭环,不来自章节数量或精确到没有依据的日期 |
| 内容逻辑 | 从已批准结果、成功标准、范围和约束出发,按“交付物 / 工作流 → 依赖与关键路径 → 带退出条件的里程碑 → owner / 接口 / 资源 → 风险触发与备选 → 治理、变更与验收”推进 |
| 事实 / 边界 | 区分已确认承诺、估算、假设和待定项时间、owner、预算、产能、权限、依赖与验收方须可追溯且算术相容未批准方向不得写成承诺关键资源或安全前提未知时收窄计划或 `blocked` |
| 错误 | 任务清单冒充计划、活动无交付物 / 完成定义、里程碑只是日期、排期不服从依赖与产能、所有事项同优先级、接口或验收方缺失、风险无预警信号 / 动作 / owner、变更后不更新基线任一出现即失败 |
## 适用与消歧
用于方向和目标已定后,组织一次性项目、迁移、发布、活动战役、专项治理或跨团队变更。主要任务仍是选择方向、申请预算 / 资源或授权时走 `proposal.md`;比较策略选项且不形成批准入口走 `business-analysis.md`;发布已授权规则 / 通知走 `formal-doc.md`;重复确定路径走 `sop-tutorial.md`;报当前状态走 `weekly-report.md`
“项目计划、执行方案、实施计划、营销策划”只作召回词。营销策划若仍在决定打法或预算,按上述 Proposal / Business Analysis 消歧;只有已定打法的协同落地走本合同。
## 可执行性与证据
- 先写可验收结果、范围 / 非范围、约束和最迟决策点;再按交付物而非部门名称拆工作包。每个关键工作包说明 owner、输入 / 输出、依赖、完成定义和验收方。
- 标出关键路径、可并行项、阶段入口 / 退出条件与资源瓶颈;日期由依赖、产能和必要审批 / 制作 / 校准时间推导。无法推导时用相对时间、区间或具体占位,不补造精确排期。
- 风险写预警信号、影响、预防 / 响应动作、决策 owner 和备选路径;备选必须说明何时切换及切换后的安全或业务终态,不写“加强沟通”。
- 治理只保留会产生判断的节奏:接口、升级条件、决策权、范围 / 基线变更和重新验收。密集对应关系可用一张排期、依赖或责任表,但表格不能替代关键路径和取舍说明。
## 高质量写法
让每个目标能一路回链到交付物、里程碑和工作包,让每个日期能回链依赖与产能,让每个风险能回链触发后的动作。资源不足时缩范围、分阶段或设决策门,不用“全渠道、全覆盖、同步推进”制造伪可行性。

View File

@@ -1,36 +0,0 @@
# Genre Contract: Formal Document / 内部正式材料 (`workplace.formal_doc`)
## 体裁规则表(硬约束)
| 规则项 | 规则 |
|-|-|
| presentation_mode / 表达模式 | 固定 `formal`;格式中立的内容稿完成后再应用。庄重、准确、简洁、直接;正式性来自真实权威、事实、边界、责任和生命周期,不来自套话、层级或装饰 |
| 允许 block | `title`(完整文稿最多 1 个)、`p``h1``h2``h3``h4`;标题层级连续且不超过四级 |
| 限用 block | `ul``ol`容器及`li`子块仅承载真实并列或顺序;`table`容器及`thead``tbody``tfoot``tr`子块仅承载多对象同字段信息;`img``figure`仅承载有必要证据作用、来源说明和文字等价信息的材料 |
| 禁止 block | 禁止未列入允许 / 限用清单的类型,包括`callout``checkbox``grid`容器及`column`子块、`whiteboard``blockquote``pre`、根级`code``hr`;禁止装饰色、贴纸、伪红头、伪印章和无证据作用的配图 |
| 内容逻辑 | 先按读者任务选择规则 / 制度、已批准通知 / 安排、检查整改 / 台账或已核定正式说明之一;只写完成该任务所需的对象、依据、要求 / 发现、责任、核验和生命周期,不混写子类型 |
| 事实 / 边界 | 只把已确认的授权、要求、事实和立场写成定论;来源陈述、原始记录、已复核事实和推断分开;外发前确认保密、商业秘密、个人信息、素材权利和发布权限;关键缺口使 `Publish Gate = blocked` |
| 错误 | 因“正式”误判公文,把本 leaf 当方案 / 总结 / 简报兜底,伪造批准 / 生效,用通知偷渡未获授权的新规则,网络素材冒充本单位事实,检查线索写成责任结论,或措施与发现不对应,任一出现即失败 |
## 适用与收口
用于把已授权的非公文组织规则或安排、可复核的检查整改记录,或已核定的组织立场写成正式载体,使读者能够判断适用范围、应采取的行动、记录状态或核心立场。
待批准方向走 `proposal.md`;复杂一次性执行走 `execution-plan.md`;重复操作步骤走 `sop-tutorial.md`;党政机关公文走 `official-redhead.md`;高层简报走 `memo-brief.md`;周期状态走 `weekly-report.md`;学习总结走 Retrospective / Report。`正式、制度、通知、方案、计划、总结、简报、讲话稿`等词单独不触发本体裁,本体裁也不是不确定请求的 fallback。
## 按读者任务选择唯一内容路径
| 读者任务 | 内容主线 |
|-|-|
| 判断持续规则 | 目的与权威 → 适用 / 不适用范围 → 必要定义 → 规范要求 → 责任、例外与升级 → 生效、维护、复审和替代 |
| 执行已批准通知 | 发布主体与批准状态 → 受影响对象及范围 → 已确认事项与生效时间 → 动作、责任与期限 → 例外、反馈和联系人 |
| 复核检查整改 | 对象、范围、方法与证据状态 → 每项可观察发现、标准、影响和已支持原因 → 对应措施、责任与期限 → 核验、关闭证据和变更痕迹 |
| 理解已核定立场 | 讲者或发布主体、场合、受众与时长 → 核心立场 → 必要事实和理由 → 期望理解或行动;不混入制度效力 |
## 证据与高质量写法
- 规则类按需写维护责任、版本、批准、生效、复审和替代状态;稳定描述做什么、谁负责、何时生效,易变操作方法链接到受控 SOP。规范词优先沿用组织现有定义强度不明时标`[规范强度待确认]`
- 检查整改区分用户陈述、原始记录、已复核事实和待补证线索;关键日期、数量或结论证据不足时就近标`[证据待补:补证动作]`,不得推断原因或责任;归档补正保留原记录。
- 检查措施必须对应具体发现并可核验;已批准通知只传达授权范围内的事项;正式讲话只使用已核定立场,并按真实语速朗读校验。
- 使用主动句、明确主体和一致术语,一句只表达一个事实、判断、要求或许可;清单严守用户指定数量与字段,不机械补背景或文控字段。
- 批准者、依据、权限、适用范围、生效状态或发布条件不明时使用具体占位并保持草案;不得以版式、标题或署名暗示已经批准、签发或生效。

Some files were not shown because too many files have changed in this diff Show More