mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 07:31:22 +08:00
Compare commits
63 Commits
codex/lark
...
feat/lark-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
020151f01b | ||
|
|
3a85ef389d | ||
|
|
68f867d6a5 | ||
|
|
78f7fba89e | ||
|
|
06241666a0 | ||
|
|
a35cc26131 | ||
|
|
b6da950be3 | ||
|
|
aa545083b6 | ||
|
|
5c7100ee4c | ||
|
|
3ef3a9d1d3 | ||
|
|
bdad336caf | ||
|
|
39a7d4bfb4 | ||
|
|
4b404fc0ee | ||
|
|
fc6e1e25de | ||
|
|
14d3107bf2 | ||
|
|
e795f4f068 | ||
|
|
2e4033a1a0 | ||
|
|
fc44564b01 | ||
|
|
7742a47072 | ||
|
|
3668b904ca | ||
|
|
1c68d31d12 | ||
|
|
4c51cd36fb | ||
|
|
bbeae3636c | ||
|
|
a9d88c5666 | ||
|
|
4801675fd6 | ||
|
|
dd04b3705f | ||
|
|
439f184ba5 | ||
|
|
825071fd7a | ||
|
|
72999cd303 | ||
|
|
f9c73e217d | ||
|
|
5f3c1c8e6a | ||
|
|
ead8aa854f | ||
|
|
833b7cde33 | ||
|
|
57d71607e1 | ||
|
|
d2c326a78c | ||
|
|
422797305a | ||
|
|
3fa28c10fa | ||
|
|
27d185c91c | ||
|
|
83926943ae | ||
|
|
752bfcbbb9 | ||
|
|
80d9f6b59b | ||
|
|
080ef44cdb | ||
|
|
f046fb6282 | ||
|
|
ca9eddb142 | ||
|
|
1caeb2d377 | ||
|
|
a66bef66af | ||
|
|
421805d35c | ||
|
|
8d5bb73c70 | ||
|
|
97b9ffb466 | ||
|
|
336f147ca6 | ||
|
|
0a47f35c7d | ||
|
|
72ac526e23 | ||
|
|
023a8786f0 | ||
|
|
3ecd75b53d | ||
|
|
5bf71428a4 | ||
|
|
e819e819fe | ||
|
|
2017e9dab8 | ||
|
|
74a02e6f2d | ||
|
|
02f4f73227 | ||
|
|
a2625d036d | ||
|
|
d005694e0f | ||
|
|
3149c77134 | ||
|
|
6e067f2180 |
30
.github/workflows/semantic-review.yml
vendored
30
.github/workflows/semantic-review.yml
vendored
@@ -47,13 +47,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -74,11 +71,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -126,7 +123,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -258,13 +255,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -285,11 +279,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -337,7 +331,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
71
AGENTS.md
71
AGENTS.md
@@ -141,3 +141,74 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
| Modify shortcut flags/params | Required | If behavior changes |
|
||||
| Shortcut bug fix | Required | If regression risk |
|
||||
| Internal refactor (no shortcut impact) | Not needed | Not needed |
|
||||
|
||||
|
||||
|
||||
## CCM Harness Skill Routing (v2)
|
||||
|
||||
When the user's request matches one of these patterns, invoke the corresponding `/ccm-harness:*` skill via the Skill tool. Skills include multi-step workflows, gates, and quality checks that produce more reliable results than ad-hoc answers. When in doubt, invoke the skill — a false positive is cheaper than a false negative.
|
||||
|
||||
### 主链路(spec → idl → dev → release)
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "新需求 / 这是个新功能 / 开始一个 req / 写 spec / 起 spec / PRD 来了 / 把 PRD 转 spec / 改 spec / 微调 spec / spec 局部更新 / spec 加一个字段" | `/ccm-harness:draft-spec <req-id> [<arg2>] [--force]`(Phase 0 路由器自动决定 init / generate / update capability)|
|
||||
| "review spec / 看 spec / 评审 spec / spec 评审" | `/ccm-harness:spec-review <req-id>` |
|
||||
| "生成 thrift / 起 idl / 把 spec 转 thrift" | `/ccm-harness:draft-idl` |
|
||||
| "推 thrift / 落 contract / Frozen Spec / codegen / 生成框架代码" | `/ccm-harness:codegen-idl <req-id>` |
|
||||
| "实现 spec / 写后端 / 后端开发 / 实现这个功能" | `/ccm-harness:backend-dev <req-id>` |
|
||||
| "前端怎么改 / 写前端 / 前端开发 / 前端编码" | `/ccm-harness:frontend-coding <req-id>` |
|
||||
| "部署 BOE / 上 PPE / 部署到 feature 环境" | `/ccm-harness:deploy <req-id>` |
|
||||
| "提 release / 上线 / 发布 / 走 PRE-GRAY-ONLINE" | `/ccm-harness:release <req-id>` |
|
||||
| "触发打包 / build / 起 SCM 编译" | `/ccm-harness:build <repo or psm>` |
|
||||
|
||||
### 守护与诊断
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "工作流到哪一步了 / 下一步咋走 / 我迷路了" | `/ccm-harness:doctor` |
|
||||
| "调试 / debug / 这个 bug 怎么排" | `/ccm-harness:debug` |
|
||||
| "查 CI 失败 / CI 跑挂了 / pipeline 红了" | `/ccm-harness:check-ci-failure <mr>` |
|
||||
| "检查实现是否符合 spec / 蓝图对比" | `/ccm-harness:check-impl-gap` |
|
||||
| "spec 跟代码飘了吗 / drift 检查" | `/ccm-harness:spec-review <req-id> --mode drift` |
|
||||
| "Hub 知识跟代码一致吗" | `/ccm-harness:check-knowledge-consistency` |
|
||||
| "代码 review / 看 MR / cr 一下" | `/ccm-harness:code-review <mr>` |
|
||||
| "设计 review / 看技术方案" | `/ccm-harness:design-review <doc>` |
|
||||
|
||||
### 工程辅助
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "通知 reviewer / 发 review 卡片" | `/ccm-harness:notify-reviewer <mr>` |
|
||||
| "自动修 MR / 按 review 改" | `/ccm-harness:autofix-mr <mr>` |
|
||||
| "生成测试用例 / 起 case / 筛选可执行 case / E2E 用例 / Playwright 脚本 / 自动化验收" | `/ccm-harness:test`(路由到 `ccm-e2e-check -> exec-e2e`) |
|
||||
| "查 idl / 看 thrift 定义" | `/ccm-harness:lookup-idl <psm>` |
|
||||
| "看仓库最近改了啥 / 仓库脉搏" | `/ccm-harness:pulse` |
|
||||
|
||||
### 元能力(Skill / Prompt 研发)
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "起一个新 skill / 设计 skill" | `/ccm-harness:meta-draft-skill` |
|
||||
| "做评测集 / 给 skill 出评测数据" | `/ccm-harness:meta-build-evalset` |
|
||||
| "跑评测 / Fornax 实验" | `/ccm-harness:meta-run-eval` |
|
||||
| "优化 skill / skill 反馈优化" | `/ccm-harness:meta-optimize-skill` |
|
||||
|
||||
### 环境与配置
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "升级 ccm-harness / 更新插件" | `/ccm-harness:upgrade` |
|
||||
| "看遥测 / 最近的反馈" | `/ccm-harness:show-telemetry` |
|
||||
| "清遥测 / 重置 telemetry" | `/ccm-harness:clear-telemetry` |
|
||||
| "上报问题 / 提 issue" | `/ccm-harness:report-issue` |
|
||||
| "看本地教训 / project learnings / 我们踩过啥" | `/ccm-harness:learn` |
|
||||
| "反馈 / 评分这次 skill" | `/ccm-harness:feedback` |
|
||||
|
||||
### 使用提示
|
||||
|
||||
- **入口**:新需求**必从** `/ccm-harness:draft-spec <req-id>` 开始(Phase 0 路由器自动决定建目录 / 带 PRD 一气呵成)。
|
||||
- **不跳步**:spec → idl → dev → release 是流水线,不是菜单——按顺序推进,反向走需要 `/ccm-harness:draft-spec <req-id> "<change-desc>"` 局部修(路由进 update capability)。
|
||||
- **横切**:任何阶段发现 spec 要局部改 → `/ccm-harness:draft-spec <req-id> "<change-desc>"`;想检测漂移 → `/ccm-harness:spec-review <req-id> --mode drift`;CI 红 → `/ccm-harness:check-ci-failure`;迷路 → `/ccm-harness:doctor`。
|
||||
|
||||
完整流程文档:`docs/user-guide/workflow.md`
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,30 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
@@ -1236,7 +1212,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
|
||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
## CCM Harness Skill Routing (v2)
|
||||
|
||||
When the user's request matches one of these patterns, invoke the corresponding `/ccm-harness:*` skill via the Skill tool. Skills include multi-step workflows, gates, and quality checks that produce more reliable results than ad-hoc answers. When in doubt, invoke the skill — a false positive is cheaper than a false negative.
|
||||
|
||||
### 主链路(spec → idl → dev → release)
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "新需求 / 这是个新功能 / 开始一个 req / 写 spec / 起 spec / PRD 来了 / 把 PRD 转 spec / 改 spec / 微调 spec / spec 局部更新 / spec 加一个字段" | `/ccm-harness:draft-spec <req-id> [<arg2>] [--force]`(Phase 0 路由器自动决定 init / generate / update capability)|
|
||||
| "review spec / 看 spec / 评审 spec / spec 评审" | `/ccm-harness:spec-review <req-id>` |
|
||||
| "生成 thrift / 起 idl / 把 spec 转 thrift" | `/ccm-harness:draft-idl` |
|
||||
| "推 thrift / 落 contract / Frozen Spec / codegen / 生成框架代码" | `/ccm-harness:codegen-idl <req-id>` |
|
||||
| "实现 spec / 写后端 / 后端开发 / 实现这个功能" | `/ccm-harness:backend-dev <req-id>` |
|
||||
| "前端怎么改 / 写前端 / 前端开发 / 前端编码" | `/ccm-harness:frontend-coding <req-id>` |
|
||||
| "部署 BOE / 上 PPE / 部署到 feature 环境" | `/ccm-harness:deploy <req-id>` |
|
||||
| "提 release / 上线 / 发布 / 走 PRE-GRAY-ONLINE" | `/ccm-harness:release <req-id>` |
|
||||
| "触发打包 / build / 起 SCM 编译" | `/ccm-harness:build <repo or psm>` |
|
||||
|
||||
### 守护与诊断
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "工作流到哪一步了 / 下一步咋走 / 我迷路了" | `/ccm-harness:doctor` |
|
||||
| "调试 / debug / 这个 bug 怎么排" | `/ccm-harness:debug` |
|
||||
| "查 CI 失败 / CI 跑挂了 / pipeline 红了" | `/ccm-harness:check-ci-failure <mr>` |
|
||||
| "检查实现是否符合 spec / 蓝图对比" | `/ccm-harness:check-impl-gap` |
|
||||
| "spec 跟代码飘了吗 / drift 检查" | `/ccm-harness:spec-review <req-id> --mode drift` |
|
||||
| "Hub 知识跟代码一致吗" | `/ccm-harness:check-knowledge-consistency` |
|
||||
| "代码 review / 看 MR / cr 一下" | `/ccm-harness:code-review <mr>` |
|
||||
| "设计 review / 看技术方案" | `/ccm-harness:design-review <doc>` |
|
||||
|
||||
### 工程辅助
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "通知 reviewer / 发 review 卡片" | `/ccm-harness:notify-reviewer <mr>` |
|
||||
| "自动修 MR / 按 review 改" | `/ccm-harness:autofix-mr <mr>` |
|
||||
| "生成测试用例 / 起 case / 筛选可执行 case / E2E 用例 / Playwright 脚本 / 自动化验收" | `/ccm-harness:test`(路由到 `ccm-e2e-check -> exec-e2e`) |
|
||||
| "查 idl / 看 thrift 定义" | `/ccm-harness:lookup-idl <psm>` |
|
||||
| "看仓库最近改了啥 / 仓库脉搏" | `/ccm-harness:pulse` |
|
||||
|
||||
### 元能力(Skill / Prompt 研发)
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "起一个新 skill / 设计 skill" | `/ccm-harness:meta-draft-skill` |
|
||||
| "做评测集 / 给 skill 出评测数据" | `/ccm-harness:meta-build-evalset` |
|
||||
| "跑评测 / Fornax 实验" | `/ccm-harness:meta-run-eval` |
|
||||
| "优化 skill / skill 反馈优化" | `/ccm-harness:meta-optimize-skill` |
|
||||
|
||||
### 环境与配置
|
||||
|
||||
| 用户表达 | Skill |
|
||||
|---------|-------|
|
||||
| "升级 ccm-harness / 更新插件" | `/ccm-harness:upgrade` |
|
||||
| "看遥测 / 最近的反馈" | `/ccm-harness:show-telemetry` |
|
||||
| "清遥测 / 重置 telemetry" | `/ccm-harness:clear-telemetry` |
|
||||
| "上报问题 / 提 issue" | `/ccm-harness:report-issue` |
|
||||
| "看本地教训 / project learnings / 我们踩过啥" | `/ccm-harness:learn` |
|
||||
| "反馈 / 评分这次 skill" | `/ccm-harness:feedback` |
|
||||
|
||||
### 使用提示
|
||||
|
||||
- **入口**:新需求**必从** `/ccm-harness:draft-spec <req-id>` 开始(Phase 0 路由器自动决定建目录 / 带 PRD 一气呵成)。
|
||||
- **不跳步**:spec → idl → dev → release 是流水线,不是菜单——按顺序推进,反向走需要 `/ccm-harness:draft-spec <req-id> "<change-desc>"` 局部修(路由进 update capability)。
|
||||
- **横切**:任何阶段发现 spec 要局部改 → `/ccm-harness:draft-spec <req-id> "<change-desc>"`;想检测漂移 → `/ccm-harness:spec-review <req-id> --mode drift`;CI 红 → `/ccm-harness:check-ci-failure`;迷路 → `/ccm-harness:doctor`。
|
||||
|
||||
完整流程文档:`docs/user-guide/workflow.md`
|
||||
@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,34 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
305
docs/specs/sheet-history-revert/spec.md
Normal file
305
docs/specs/sheet-history-revert/spec.md
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
req_id: sheet-history-revert
|
||||
mode: from-prd
|
||||
tracks: [be]
|
||||
created_at: 2026-06-23T13:09:08Z
|
||||
---
|
||||
|
||||
> ⚠️ **请勿直接编辑此文档**
|
||||
> 修改请通过 `/ccm-harness:draft-spec sheet-history-revert "<变更描述>"`(路由器会进入 update capability)
|
||||
> 手改不会被 check-spec-drift 放过,且会破坏 Frozen 快照关联性
|
||||
> 本文档由 /ccm-harness:draft-spec 生成于 2026-06-23T13:09:08Z;req-id:sheet-history-revert
|
||||
|
||||
---
|
||||
|
||||
# Sheet 历史版本查询与回滚
|
||||
|
||||
<!-- BEGIN generated overview — scripts/render_spec_overview.py 自动生成,勿手改;改 spec 请走 /ccm-harness:draft-spec <req-id> "<变更>"(update) -->
|
||||
## 方案概览 / TL;DR
|
||||
|
||||
> 本段由机器从下方子任务字段**确定性生成**,供快速把握方案;**权威细节仍以各子任务 `yaml` 块为准**。
|
||||
|
||||
- **需求** `sheet-history-revert` | 模式 `from-prd` | 端 BE
|
||||
- **规模** 5 BE | **涉及 PSM** `tooling.lark_cli`, `tooling.sheet_skill_spec`, `sheet.facade.agg`, `bear.server.sheet_data` | **Thrift 影响** 无 ×5
|
||||
|
||||
| 子任务 | 一句话 | 关键信息 |
|
||||
|---|---|---|
|
||||
| BE-1 | lark-cli `+history-list` 历史记录列表 shortcut | `tooling.lark_cli` · thrift:无 · `history_list[read](callTool tools/invoke_read;内部对接 /space/api/v3/sheet/histories)` |
|
||||
| BE-2 | lark-cli `+history-revert` 与 `+history-revert-status` 回滚流 shortcut | `tooling.lark_cli` · thrift:无 · `history_revert[write] / history_revert_status[read](callTool tools/invoke_write\|read;内部对接 /space/api/v2/sheet/recover、/api/v2/sheet/recover/status)` |
|
||||
| BE-3 | sheet-skill-spec 上游事实源(skill 正文 + shortcut/flag 定义) | `tooling.sheet_skill_spec` · thrift:无 · `A` |
|
||||
| BE-4 | sheet-facade-agg 在现有 ToolsCall 上新增 3 个历史/回滚工具 | `sheet.facade.agg` · thrift:无 · `history_list[read] / history_revert[write] / history_revert_status[read]` |
|
||||
| BE-5 | sheet/data 透传 scene 并在 RecoverMsg 上新增 Scene 字段 | `bear.server.sheet_data` · thrift:无 · `RecoverHistory / QueryRecoverStatus(复用);MQ RecoverMsg 加 Scene 字段` |
|
||||
|
||||
> 完整字段见各子任务 `yaml` 块。
|
||||
<!-- END generated overview -->
|
||||
|
||||
## 概述与范围
|
||||
|
||||
为 `lark-cli` 的 lark-sheets 能力补齐**电子表格历史版本查询与回滚**,新增 3 个 shortcut,让 AI / 用户可以列出某张表的历史版本、回滚到指定版本、并查询回滚的异步状态。三个 shortcut 封装的是飞书电子表格已上线的 space 接口(见下表的「前端接口参考」),本需求不新建产品能力,只是把它们包装成稳定、AI 友好的命令面。
|
||||
|
||||
| 功能 | shortcut | 前端接口参考(搜索 lark/idl) | 实现差异点 |
|
||||
|---|---|---|---|
|
||||
| 查历史记录列表 | `+history-list` | `/space/api/v3/sheet/histories` | ① 仅返回 `minor_histories` 列表;② `minor_histories` 的 `id` 字段重命名为 `history_version_id`;③ 每条仅保留 `history_version_id` / `create_time`(序列化成 AI 优化的可读格式)/ `action` / `all_block_revision` 四个字段 |
|
||||
| 历史记录回滚 | `+history-revert` | `/space/api/v2/sheet/recover` | 传入 `+history-list` 拿到的 `history_version_id`,回滚到指定版本 |
|
||||
| 查询回滚状态 | `+history-revert-status` | `/api/v2/sheet/recover/status` | 查询 `+history-revert` 发起的异步回滚的当前状态 |
|
||||
|
||||
**范围内**:
|
||||
- `larksuite/cli` 仓新增 3 个 sheets shortcut(含 `+history-list` 的响应裁剪 / 字段重命名 / 时间格式转换逻辑)。
|
||||
- `ee/sheet-skill-spec` 上游事实源补 skill 正文与 shortcut/flag 定义,经其工作流 `sync:cli` 同步到 `larksuite/cli` 的 `skills/lark-sheets/` 与 `shortcuts/sheets/data/`。
|
||||
- `ee/sheet-facade-agg` **复用现有 `ToolsCall` 接口**,在其上新增 3 个工具(`history_list` / `history_revert` / `history_revert_status`);并在**回滚消息消费侧**(消费 `sheet/data` 产出的 RecoverMsg)按 scene 给 `memberId` 赋值(doubao=10,lark-cli=11)后构造 recover cs。
|
||||
- `sheet/data`(`bear.server.sheet_data`)把 scene 从入口透传到 `RecoverHistory`,并在产出的 `RecoverMsg`(MQ 消息)上**新增 `Scene` 字段**,供 agg 消费时区分场景。
|
||||
|
||||
**范围外**:
|
||||
- 历史版本的底层存储 / 快照 / 过期清理逻辑、`RecoverHistory` 的回滚业务语义(`bear.server.sheet_data` 已有,本需求仅透传 scene + 加 MQ 字段,不改回滚逻辑本身)。
|
||||
- 历史版本 diff、可视化、权限模型变更等产品侧扩展。
|
||||
- `doubao-office` 消费方的同步(`sheet-skill-spec` 另有 `sync:doubao`,本需求不涉及)。
|
||||
|
||||
## 服务拓扑与 PSM 变更判定表
|
||||
|
||||
**调用链路(目标形态)**:
|
||||
|
||||
```
|
||||
用户 / AI ──> lark-cli (+history-list / +history-revert / +history-revert-status)
|
||||
│ callTool → POST /open-apis/sheet_ai/v2/.../tools/invoke_read|write(scene 随入口确定)
|
||||
▼
|
||||
sheet.facade.agg ToolsCall(复用现有接口,新增 3 个工具:
|
||||
history_list[read] / history_revert[write] / history_revert_status[read])
|
||||
├─ history_list ──> 拉历史列表(裁剪 minor_histories / 4 字段 / AI 时间格式)
|
||||
├─ history_revert ───scene(ctx 透传)──> bear.server.sheet_data: RecoverHistory
|
||||
│ │ service.RecoverHistory → SendRecoverMsg
|
||||
│ ▼
|
||||
│ MQ: RecoverMsg(新增 Scene 字段)
|
||||
│ ▼
|
||||
│ sheet.facade.agg RecoverMsg 消费者 ── 读 Scene → memberId(doubao=10/lark-cli=11)
|
||||
│ → 构造 recover cs → 调既有 recover 下游
|
||||
└─ history_revert_status ──> bear.server.sheet_data: QueryRecoverStatus(transactionID)
|
||||
```
|
||||
|
||||
`lark-cli` 现有 sheets shortcut 统一通过 `callTool`(`shortcuts/sheets/sheet_ai_api.go`)走 One-OpenAPI 的 `tools/invoke_read|write` 入口(`ToolKindRead` / `ToolKindWrite`),由 `sheet.facade.agg` 的 `OpenAPIToolCallRead/Write`(`biz/handler/lark_cli.go`,底层复用 `aiService.ToolsCall`)按 `tool_name` 分发。本需求的 3 个 shortcut 即按此路径新增 3 个工具,而非直连 space 接口或新增独立 OpenAPI 路由。
|
||||
|
||||
**回滚为异步两段式**:`history_revert` 工具调 `sheet/data` 的 `RecoverHistory`(`biz/history/service/recover.go`),后者通过 `SendRecoverMsg`(`infra/mq/producer/recover.go` 的 `RecoverMsg`)投递回滚消息并返回 `transactionID`;agg 侧的 RecoverMsg 消费者真正构造 recover cs,并在此时按 scene 给 `memberId` 赋值。`history_revert_status` 工具走 `sheet/data` 的 `QueryRecoverStatus(transactionID)` 查询异步结果。scene 从 ToolsCall 入口经 **ctx 透传**(沿用既有 `utils.WithSceneDoubao(ctx)` 范式)到 `RecoverHistory`,再写入 `RecoverMsg.Scene` 字段,使 agg 消费时可区分 doubao / lark-cli。
|
||||
|
||||
**PSM 变更判定表**:
|
||||
|
||||
| PSM | 需要代码变更? | 变更内容 | 不变更原因 |
|
||||
|---|---|---|---|
|
||||
| `tooling.lark_cli`(`larksuite/cli`,无服务 PSM) | 是 | 新增 3 个 shortcut + `+history-list` 响应 transform | — |
|
||||
| `tooling.sheet_skill_spec`(`ee/sheet-skill-spec`,无服务 PSM) | 是 | 新增 lark-sheets skill 正文 + 3 个 shortcut/flag 定义,生成后同步到 cli | — |
|
||||
| `sheet.facade.agg`(`ee/sheet-facade-agg`) | 是 | ① 现有 `ToolsCall` 新增 3 个工具(`history_list[read]` / `history_revert[write]` / `history_revert_status[read]`);② `history_revert` 工具调 `sheet/data` 时透传 scene(ctx);③ **RecoverMsg 消费者**读 `Scene` 字段,按 scene 给 `memberId` 赋值后构造 recover cs。**无新 thrift**(工具按 `tool_name`+JSON 注册,scene 走 ctx baggage) | — |
|
||||
| `bear.server.sheet_data`(`sheet/data`) | 是 | scene 从入口透传到 `RecoverHistory`(`biz/history/service/recover.go`),并在 `RecoverMsg`(`infra/mq/producer/recover.go`)上**新增 `Scene` 字段**随消息投递。`RecoverMsg` 是 JSON Go struct,加字段**非 thrift**;scene 透传走 ctx baggage | 回滚 / 快照业务语义不变(`RestoreHistorySnapshot` / `QueryRecoverStatus` 等复用) |
|
||||
|
||||
> **`memberId` 按 scene 赋值(实现硬约束,跨 sheet/data + agg)**:scene 区分 doubao 与 lark-cli(沿用既有 `utils.WithSceneDoubao(ctx)` 范式)。本需求要求 scene 从 ToolsCall 入口一路透传:
|
||||
> 1. agg `history_revert` 工具调用 `sheet/data.RecoverHistory` 时,把 scene 经 ctx 透传;
|
||||
> 2. `sheet/data` 在产出的 `RecoverMsg` 上写入 `Scene` 字段,随 MQ 投递;
|
||||
> 3. **agg 的 RecoverMsg 消费者**在真正构造 recover cs 时,读 `Scene` 给 `memberId` 赋值——doubao 场景 = `10`,其他(lark-cli)场景 = `11`。
|
||||
>
|
||||
> memberId 赋值发生在 **agg 消费侧**(不是同步 ToolsCall 调用栈,因为回滚是异步消息驱动)。错误的 memberId 会导致回滚归属错误的调用方身份(审计 / 权限相关)。`RecoverMsg.MemberId` 字段已存在,但本需求要求按 scene 正确赋值并据此区分两个消费方。
|
||||
|
||||
## 后端 / Tooling 子任务
|
||||
|
||||
> 说明:`lark-cli` 与 `sheet-skill-spec` 均属 Tooling,按 ccm-harness 约定建 BE-*,`thrift_impact: 无`。本需求无前端(FE)子任务。
|
||||
|
||||
### BE-1: lark-cli `+history-list` 历史记录列表 shortcut
|
||||
|
||||
```yaml
|
||||
psm: tooling.lark_cli
|
||||
repo: larksuite/cli
|
||||
module: shortcuts/sheets
|
||||
be_deploy_required: false
|
||||
thrift_impact: 无
|
||||
api: facade-agg ToolsCall::history_list[read](callTool tools/invoke_read;内部对接 /space/api/v3/sheet/histories)
|
||||
depends_on: [BE-4]
|
||||
estimate: 1.5d
|
||||
```
|
||||
|
||||
**调用的下游服务**:经 `callTool(ToolKindRead, "history_list", ...)`(`shortcuts/sheets/sheet_ai_api.go`)走 `tools/invoke_read` 入口,由 facade-agg 的 `history_list` 工具内部对接 `/space/api/v3/sheet/histories`。入参:表格 token(沿用现有 sheets shortcut 的 `--spreadsheet-token` / `--token` 解析)。响应裁剪 / 字段重命名 / 时间格式可在 facade-agg 工具侧或 lark-cli 侧完成(见实现要点;以 AI 友好输出为准)。
|
||||
**实现要点(实现差异点落地)**:
|
||||
- 仅取响应中的 `minor_histories` 列表,丢弃其余顶层字段(如 major histories)。
|
||||
- 将每条 `minor_histories` 的 `id` 字段重命名输出为 `history_version_id`。
|
||||
- 每条仅保留 4 个字段:`history_version_id`、`create_time`、`action`、`all_block_revision`。
|
||||
- `create_time` 序列化成 AI 优化的可读格式(如本地时区可读时间串),而非裸 unix 时间戳。
|
||||
**验收场景**:
|
||||
- Given 一张有多个历史版本的电子表格,When 执行 `lark-cli sheets +history-list --token <t>`,Then 返回 JSON 数组,每条恰好含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个键,且 `create_time` 为可读格式。
|
||||
- Given 一张无历史记录的表格,When 执行 `+history-list`,Then 返回空列表且退码 0(不报错)。
|
||||
|
||||
### BE-2: lark-cli `+history-revert` 与 `+history-revert-status` 回滚流 shortcut
|
||||
|
||||
```yaml
|
||||
psm: tooling.lark_cli
|
||||
repo: larksuite/cli
|
||||
module: shortcuts/sheets
|
||||
be_deploy_required: false
|
||||
thrift_impact: 无
|
||||
api: facade-agg ToolsCall::history_revert[write] / history_revert_status[read](callTool tools/invoke_write|read;内部对接 /space/api/v2/sheet/recover、/api/v2/sheet/recover/status)
|
||||
depends_on: [BE-1, BE-4, BE-5]
|
||||
estimate: 1.5d
|
||||
```
|
||||
|
||||
**调用的下游服务**:
|
||||
- `+history-revert` → `callTool(ToolKindWrite, "history_revert", ...)`,agg 工具调 `sheet/data.RecoverHistory`(异步),返回 `transactionID`。
|
||||
- `+history-revert-status` → `callTool(ToolKindRead, "history_revert_status", ...)`,agg 工具调 `sheet/data.QueryRecoverStatus(transactionID)` 查异步结果。
|
||||
- 注:`memberId` 按 scene 赋值(doubao=10 / lark-cli=11)发生在 agg 的 **RecoverMsg 消费者**侧(见 BE-4 / BE-5),lark-cli 侧不感知;scene 由 callTool 入口(read/write)确定。
|
||||
**实现要点**:
|
||||
- `+history-revert` 的 `--history-version-id`(命名对齐 BE-1 的输出字段)为必填;缺失时在 Validate 阶段给出可执行错误提示。
|
||||
- 回滚为异步操作,`+history-revert` 返回受理结果,`+history-revert-status` 供轮询最终状态(成功 / 进行中 / 失败)。
|
||||
**验收场景**:
|
||||
- Given 由 `+history-list` 取得的合法 `history_version_id`,When 执行 `+history-revert --token <t> --history-version-id <id>`,Then 后端受理回滚并返回可被 `+history-revert-status` 查询的标识。
|
||||
- Given 一次已发起的回滚,When 轮询 `+history-revert-status`,Then 能区分「进行中 / 成功 / 失败」三种状态。
|
||||
- Given 缺省 `--history-version-id`,When 执行 `+history-revert`,Then 返回明确的参数缺失错误,不发起请求。
|
||||
|
||||
### BE-3: sheet-skill-spec 上游事实源(skill 正文 + shortcut/flag 定义)
|
||||
|
||||
```yaml
|
||||
psm: tooling.sheet_skill_spec
|
||||
repo: ee/sheet-skill-spec
|
||||
module: canonical-spec
|
||||
be_deploy_required: false
|
||||
thrift_impact: 无
|
||||
api: N/A
|
||||
depends_on: [BE-1, BE-2]
|
||||
estimate: 1d
|
||||
```
|
||||
|
||||
**调用的下游服务**:无(构建期工作流)。
|
||||
**实现要点(按 `sheet-skill-spec` README 工作流)**:
|
||||
- 在飞书 base 表登记 3 个新 shortcut 的 tool ↔ shortcut 映射与 flag 定义,`npm run sync:tool-shortcut-map` 镜像入仓。
|
||||
- 在 `canonical-spec/references/<相关 skill>/cli-reference.md` 补三个 shortcut 的描述 / 示例 / Validate-DryRun-Execute 约束。
|
||||
- 跑 `npm run generate:all && npm run check:all` 验证,产出 `generated/lark-cli/skills/lark-sheets/` 与 `generated/lark-cli/data/{flag-defs.json,flag-schemas.json}`。
|
||||
- 跑 `npm run sync:cli` 把 generated 同步到 `larksuite/cli` 的 `skills/lark-sheets/`(mirror)与 `shortcuts/sheets/data/`(mirror),在 cli 仓作为 PR 提交。
|
||||
**边界**:skill 命名 / 切分 / 正文 / flag 定义一律先落 `sheet-skill-spec`,禁止直接改 cli 仓的 `generated`/`skills/lark-sheets/` 产物(README「对齐原则」)。
|
||||
**验收场景**:
|
||||
- Given 在 `sheet-skill-spec` 完成上述编辑,When 跑 `npm run check:all`,Then 全部门禁通过(generated 与 canonical 一致、map 与 base 表一致)。
|
||||
- Given 跑 `npm run sync:cli`,Then cli 仓 `skills/lark-sheets/` 与 `shortcuts/sheets/data/` 出现对应 3 个 shortcut 的 skill 正文与 flag 定义。
|
||||
|
||||
### BE-4: sheet-facade-agg 在现有 ToolsCall 上新增 3 个历史/回滚工具
|
||||
|
||||
```yaml
|
||||
psm: sheet.facade.agg
|
||||
repo: ee/sheet-facade-agg
|
||||
module: biz/handler
|
||||
be_deploy_required: true
|
||||
thrift_impact: 无
|
||||
api: ToolsCall::history_list[read] / history_revert[write] / history_revert_status[read]
|
||||
depends_on: []
|
||||
estimate: 1.5d
|
||||
```
|
||||
|
||||
**调用的下游服务**:`sheet/data` 的 `RecoverHistory` / `QueryRecoverStatus`(见 BE-5)+ 既有历史查询;agg 的 RecoverMsg 消费者复用既有 recover 下游(`biz/service/spreadsheet.go::ProcessRecoverCs`、`model.RecoverParam`),不新增 thrift。
|
||||
**实现要点**:
|
||||
- **ToolsCall 扩展**:在现有 `ToolsCall` 框架(`biz/handler/handler.go::ToolsCall` / `biz/handler/lark_cli.go::OpenAPIToolCallRead|Write`)注册 3 个新工具:`history_list`(read)、`history_revert`(write)、`history_revert_status`(read),按 `constants.IsReadTool` / `IsWriteTool` 归类,从 `tools/invoke_read` / `invoke_write` 入口可达。
|
||||
- **scene 透传**:`history_revert` / `history_revert_status` 工具调 `sheet/data` 时,把 scene 经 ctx(沿用 `utils.WithSceneDoubao` 范式)透传下去,使 `sheet/data` 能写入 `RecoverMsg.Scene`。
|
||||
- **RecoverMsg 消费者按 scene 赋 memberId(硬约束)**:agg 消费 `sheet/data` 投递的 `RecoverMsg`、构造真正 recover cs 时,读 `RecoverMsg.Scene` 给 `memberId` 赋值——doubao = `10`,lark-cli = `11`。这是异步消费侧逻辑,不在同步 ToolsCall 调用栈。
|
||||
- `history_list` 工具对接历史列表查询;响应裁剪(仅 `minor_histories`、`id`→`history_version_id`、4 字段、`create_time` AI 友好格式)建议落在此工具侧(两个消费方共享,避免 lark-cli / doubao 双实现漂移)。
|
||||
**边界**:只在 ToolsCall 上加工具 + 改 RecoverMsg 消费者;不新增独立 OpenAPI 路由、不改 `ai.ToolsCallRequest` thrift 契约、不改 `RecoverHistory` 回滚业务语义。
|
||||
**验收场景**:
|
||||
- Given lark-cli 经 `tools/invoke_read` 调用 `history_list`,Then 返回裁剪后的 `minor_histories`(4 字段,`history_version_id` 命名)。
|
||||
- Given lark-cli(scene=lark-cli)经 `history_revert` 发起回滚,When agg 消费对应 RecoverMsg,Then 构造的 recover cs 中 `memberId == 11`;doubao 场景下同一路径 `memberId == 10`。
|
||||
- Given 已发起回滚,When 调用 `history_revert_status`,Then 经 `QueryRecoverStatus` 返回可区分的回滚状态。
|
||||
|
||||
### BE-5: sheet/data 透传 scene 并在 RecoverMsg 上新增 Scene 字段
|
||||
|
||||
```yaml
|
||||
psm: bear.server.sheet_data
|
||||
repo: sheet/data
|
||||
module: biz/history
|
||||
be_deploy_required: true
|
||||
thrift_impact: 无
|
||||
api: bear.server.sheet_data::RecoverHistory / QueryRecoverStatus(复用);MQ RecoverMsg 加 Scene 字段
|
||||
depends_on: []
|
||||
estimate: 1d
|
||||
```
|
||||
|
||||
**调用的下游服务**:复用既有回滚链路(`biz/history/service/recover.go::RecoverHistory` → `infra/mq/producer/recover.go::SendRecoverMsg`)。
|
||||
**实现要点**:
|
||||
- **scene 透传**:把 agg 经 ctx 传入的 scene 接住,贯穿 `RecoverHistory`(`biz/history/service/recover.go`)到 `RecoverMsg` 构造处。
|
||||
- **RecoverMsg 加 `Scene` 字段**:在 `infra/mq/producer/recover.go` 的 `RecoverMsg` struct 上新增 `Scene` 字段并在投递时赋值。`RecoverMsg` 是 JSON Go struct(`recoverProducer.NewMessage`),**加字段非 thrift**——`thrift_impact: 无`。
|
||||
- `QueryRecoverStatus` 与回滚业务语义保持不变,仅承载 scene 透传。
|
||||
**边界**:不改回滚 / 历史快照业务逻辑;只加 scene 透传与 `RecoverMsg.Scene` 字段。
|
||||
**scene 透传方式(已定)**:经 **ctx baggage**(`metainfo` / 沿用既有 `utils.WithSceneDoubao(ctx)` 范式)从 agg 透传到 `RecoverHistory`,**不**在 `RecoverHistoryReq` thrift 上加字段 → 零 IDL 变更,`thrift_impact: 无`。
|
||||
**验收场景**:
|
||||
- Given agg 以 scene=lark-cli 调 `RecoverHistory`,Then 投递的 `RecoverMsg.Scene` 标识 lark-cli;doubao 同理。
|
||||
- Given 回滚已发起,When `QueryRecoverStatus(transactionID)`,Then 返回回滚状态(语义与现状一致)。
|
||||
- Given lark-cli(scene=lark-cli)经 `tools/invoke_write` 调用 `history_revert`,Then 构造的 recover cs 中 `memberId == 11`;doubao 场景下同一工具 `memberId == 10`。
|
||||
- Given 已发起回滚,When 调用 `history_revert_status`,Then 返回可区分的回滚状态。
|
||||
|
||||
## API 契约引用
|
||||
|
||||
本需求三个接口均为飞书电子表格已上线 space 接口,契约以各仓库最新 master 为准;对应 thrift 定义按 PRD 提示在 `lark/idl` 中搜索确认(实现阶段补全精确路径):
|
||||
|
||||
- 查列表:`/space/api/v3/sheet/histories`(取 `minor_histories`)
|
||||
- 回滚:`/space/api/v2/sheet/recover`
|
||||
- 回滚状态:`/api/v2/sheet/recover/status`
|
||||
|
||||
> 契约本体不进本 spec 正文;精确 `lark/idl/...thrift::Service::Method` 路径在实现阶段确认并回填到对应 BE-* 的 `api` 字段说明。
|
||||
|
||||
## 验收场景(汇总)
|
||||
|
||||
- 列表:`+history-list` 仅返回 `minor_histories`,每条恰好 4 个字段,`id` 重命名为 `history_version_id`,`create_time` 为 AI 优化可读格式。
|
||||
- 回滚:`+history-revert` → agg `history_revert` → `sheet/data.RecoverHistory`(异步),受理后返回可查询标识。
|
||||
- memberId/scene:agg 消费 `RecoverMsg` 构造 recover cs 时,按 `RecoverMsg.Scene` 赋 `memberId`——lark-cli=11、doubao=10(facade-agg 侧单测断言)。
|
||||
- 状态:`+history-revert-status` → `QueryRecoverStatus` 能查询并区分回滚的进行中 / 成功 / 失败。
|
||||
- skill 同步:`sheet-skill-spec` 生成产物经 `sync:cli` 落地到 cli 仓,`check:all` 全绿。
|
||||
- 三个 shortcut 在 cli 中遵循统一的 Validate / DryRun / Execute 三段约定与现有 sheets shortcut 一致。
|
||||
|
||||
## 非功能要求与约束
|
||||
|
||||
- **复用既有模式**:3 个 shortcut 必须沿用 `shortcuts/sheets` 现有的 token 解析(`--spreadsheet-token` / `--token` 别名)、错误封装(`errs`)、`callTool`(`tools/invoke_read|write`)调用与 DryRun 渲染范式,不另起调用框架。facade-agg 侧必须复用现有 `ToolsCall` 接口扩展工具,不新增独立 OpenAPI 路由。
|
||||
- **AI 友好输出**:`+history-list` 的字段裁剪与 `create_time` 可读格式是硬约束(PRD「实现差异点」),目的是降低 AI 消费成本。
|
||||
- **工作流约束**:skill 内容与 flag 定义的唯一事实源是 `ee/sheet-skill-spec`;cli 仓的 `skills/lark-sheets/` 与 `shortcuts/sheets/data/` 为同步产物,不手改。
|
||||
- **回滚为异步**:`+history-revert` 与 `+history-revert-status` 分离,调用方需理解「发起 → 轮询」两步语义。
|
||||
- **事实基准**:所有外部仓库事实(space 接口、facade-agg 路由、sheet_data 能力)以各仓库最新 master 为准。
|
||||
|
||||
## 安全设计
|
||||
|
||||
- security_knowledge_ref: UNCONFIGURED
|
||||
- 风险判断依据: 未配置安全知识库,待安全侧补齐。需关注点(供安全侧复核):`+history-revert` 是**写 / 不可逆**操作(覆盖当前表格内容到历史版本),必须校验操作者对目标表格具备编辑 / 回滚权限;历史版本列表可能暴露协作者操作痕迹(`action` 字段),需确认读权限边界。
|
||||
- 身份归属风险(memberId/scene): `memberId` 须按 scene 正确赋值(lark-cli=11 / doubao=10)。错配会使回滚操作归属错误的调用方身份,影响审计与权限判定——属安全/审计相关,须在 agg RecoverMsg 消费侧保证赋值正确。
|
||||
- 需要安全侧补充: 回滚操作的权限校验口径、历史 `action` 字段的可见性范围是否需脱敏、memberId 与真实操作者身份的映射是否需对齐审计要求。
|
||||
|
||||
## Codegen Delivery Plan
|
||||
|
||||
applicable: true
|
||||
|
||||
### A. Branch Plan
|
||||
|
||||
| `key` | value |
|
||||
|---|---|
|
||||
| `psm` | `sheet.facade.agg` |
|
||||
| `business_branch` | `feat/sheet-history-revert` |
|
||||
| `generated_branch` | `N/A` |
|
||||
| `idl_branch` | `N/A` |
|
||||
| `kitex_branch` | `N/A` |
|
||||
|
||||
### B. Delivery Targets
|
||||
|
||||
| repo | required | branch | artifact_paths | reason |
|
||||
|---|---|---|---|---|
|
||||
| larksuite/cli | yes | feat/sheet-history-revert | shortcuts/sheets/ , skills/lark-sheets/ | 3 个 shortcut 实现 + 同步落地的 skill 正文与 flag 数据 |
|
||||
| ee/sheet-skill-spec | yes | feat/sheet-history-revert | canonical-spec/references/ , generated/lark-cli/ | skill / flag 上游事实源,生成后 sync 到 cli |
|
||||
| ee/sheet-facade-agg | yes | feat/sheet-history-revert | biz/handler/ | 现有 ToolsCall 新增 3 个工具 + scene 透传 + RecoverMsg 消费者按 scene 赋 memberId |
|
||||
| sheet/data | yes | feat/sheet-history-revert | biz/history/ , infra/mq/producer/ | RecoverHistory 透传 scene + RecoverMsg 新增 Scene 字段(JSON,非 thrift) |
|
||||
|
||||
### C. Generation Decision
|
||||
|
||||
| `key` | value |
|
||||
|---|---|
|
||||
| `needs_kitex_gen` | no |
|
||||
| `needs_apacana` | no |
|
||||
| `needs_kite_via_sdp` | no |
|
||||
| `decision_basis` | facade-agg 复用现有 ToolsCall 框架按 tool_name 注册 3 个工具(JSON input,不动 ai.ToolsCallRequest thrift);sheet/data 仅在 RecoverMsg(JSON Go struct)加 Scene 字段 + 经 ctx baggage 透传 scene,复用既有 RecoverHistory/QueryRecoverStatus,均非 thrift;lark-cli 经现有 callTool 包装。scene 已定走 ctx baggage(不加 RecoverHistoryReq thrift 字段),无新增/修改 kitex/apacana/SDP 契约 |
|
||||
|
||||
### D. Branch Naming Rule
|
||||
|
||||
业务分支统一用 `feat/sheet-history-revert`;本需求无 codegen,故 `generated_branch` / `idl_branch` / `kitex_branch` 均为 `N/A`。
|
||||
|
||||
## thrift 变更需求清单
|
||||
|
||||
无(按推荐实现路径)。三个接口的能力已存在;本需求的新增内容是:facade-agg 在 ToolsCall 上注册 3 个工具(`tool_name`+JSON,不动 `ai.ToolsCallRequest`)、sheet/data 在 `RecoverMsg`(JSON Go struct)上加 `Scene` 字段、scene 经 **ctx baggage** 透传——均不涉及 thrift。
|
||||
|
||||
**scene 透传方式:已定为 ctx baggage**(`metainfo` / 沿用既有 `utils.WithSceneDoubao(ctx)` 范式),明确**不**在 `bear.server.sheet_data` 的 `RecoverHistoryReq` thrift 上加字段。故本需求确无任何 thrift struct / RPC method / enum 的新增或修改,Generation Decision 三路保持 `no`、无 codegen。
|
||||
|
||||
## N. AI Capability Manifest
|
||||
|
||||
applicable: false
|
||||
|
||||
本需求为确定性 CLI 命令封装,不含 LLM / prompt 驱动的 AI 能力。`+history-list` 中「`create_time` 序列化成 AI 优化格式」仅指对机器/AI 更易读的时间字符串格式化,属确定性数据转换,非 AI capability。
|
||||
@@ -1,132 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||
type CardActionTriggerOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||
}
|
||||
|
||||
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Operator struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"operator"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
Action struct {
|
||||
Tag string `json:"tag"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
Name string `json:"name"`
|
||||
FormValue map[string]interface{} `json:"form_value"`
|
||||
InputValue string `json:"input_value"`
|
||||
Option string `json:"option"`
|
||||
Options []string `json:"options"`
|
||||
Checked bool `json:"checked"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"action"`
|
||||
Context struct {
|
||||
OpenMessageID string `json:"open_message_id"`
|
||||
OpenChatID string `json:"open_chat_id"`
|
||||
} `json:"context"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||
}
|
||||
|
||||
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||
|
||||
out := &CardActionTriggerOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
OperatorID: envelope.Event.Operator.OpenID,
|
||||
MessageID: envelope.Event.Context.OpenMessageID,
|
||||
ChatID: envelope.Event.Context.OpenChatID,
|
||||
Host: envelope.Event.Host,
|
||||
Token: envelope.Event.Token,
|
||||
ActionTag: envelope.Event.Action.Tag,
|
||||
ActionValue: actionValue,
|
||||
ActionName: envelope.Event.Action.Name,
|
||||
FormValue: formValue,
|
||||
InputValue: envelope.Event.Action.InputValue,
|
||||
Option: envelope.Event.Action.Option,
|
||||
Options: options,
|
||||
Checked: envelope.Event.Action.Checked,
|
||||
Timezone: envelope.Event.Action.Timezone,
|
||||
}
|
||||
|
||||
if out.MessageID != "" && rt != nil {
|
||||
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// fetchCardUserDSL gets the card message content via message get API.
|
||||
// Returns empty string on any failure — never blocks event consumption.
|
||||
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Data.Items[0].Body.Content
|
||||
}
|
||||
|
||||
func marshalToString(m map[string]interface{}) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("card.action.trigger")
|
||||
if !ok {
|
||||
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("card.action.trigger must set Schema.Custom")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("card.action.trigger must set Process")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Button(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_btn_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_operator"},
|
||||
"token": "c-token-btn",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "approve"},
|
||||
"name": "approve_btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_msg_001",
|
||||
"open_chat_id": "oc_chat_001"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Type != "card.action.trigger" {
|
||||
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_btn_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" {
|
||||
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
if out.ActionValue != `{"key":"approve"}` {
|
||||
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||
}
|
||||
if out.ActionName != "approve_btn" {
|
||||
t.Errorf("ActionName = %q", out.ActionName)
|
||||
}
|
||||
if out.Token != "c-token-btn" {
|
||||
t.Errorf("Token = %q", out.Token)
|
||||
}
|
||||
if out.MessageID != "om_msg_001" {
|
||||
t.Errorf("MessageID = %q", out.MessageID)
|
||||
}
|
||||
if out.ChatID != "oc_chat_001" {
|
||||
t.Errorf("ChatID = %q", out.ChatID)
|
||||
}
|
||||
if out.Host != "im_message" {
|
||||
t.Errorf("Host = %q", out.Host)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_form_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_form_user"},
|
||||
"token": "c-token-form",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "submit_btn",
|
||||
"form_value": {"name": "test-user", "reason": "testing"},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_form_001",
|
||||
"open_chat_id": "oc_chat_002"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||
t.Errorf("FormValue = %q", out.FormValue)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ms_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_ms_user"},
|
||||
"token": "c-token-ms",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "multi_select_static",
|
||||
"value": {},
|
||||
"name": "multi_select",
|
||||
"options": ["opt_1", "opt_3"],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_ms_001",
|
||||
"open_chat_id": "oc_chat_003"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Options != "opt_1,opt_3" {
|
||||
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||
}
|
||||
if out.ActionTag != "multi_select_static" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Input(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_input_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469276"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_input_user"},
|
||||
"token": "c-token-input",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "input",
|
||||
"value": {},
|
||||
"name": "text_input",
|
||||
"input_value": "hello world",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_input_001",
|
||||
"open_chat_id": "oc_chat_004"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.InputValue != "hello world" {
|
||||
t.Errorf("InputValue = %q", out.InputValue)
|
||||
}
|
||||
if out.ActionTag != "input" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_date_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469277"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_date_user"},
|
||||
"token": "c-token-date",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "date_picker",
|
||||
"value": {},
|
||||
"name": "date_selector",
|
||||
"option": "2024-04-01 +0800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_date_001",
|
||||
"open_chat_id": "oc_chat_005"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Option != "2024-04-01 +0800" {
|
||||
t.Errorf("Option = %q", out.Option)
|
||||
}
|
||||
if out.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("Timezone = %q", out.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ok",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469278"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user"},
|
||||
"token": "c-token-mg",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "click"},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_001",
|
||||
"open_chat_id": "oc_chat_mg"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||
mock := &mockAPIClient{resp: `{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [{
|
||||
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent == "" {
|
||||
t.Error("CardContent should not be empty when message get succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ec",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469279"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user2"},
|
||||
"token": "c-token-mg2",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_002",
|
||||
"open_chat_id": "oc_chat_mg2"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_fail",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469280"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user3"},
|
||||
"token": "c-token-mg3",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_003",
|
||||
"open_chat_id": "oc_chat_mg3"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{errResp: true}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_no_msg",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469281"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_no_msg"},
|
||||
"token": "c-token-nm",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "",
|
||||
"open_chat_id": "oc_chat_nm"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
type mockAPIClient struct {
|
||||
resp string
|
||||
errResp bool
|
||||
}
|
||||
|
||||
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if m.errResp {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return json.RawMessage(m.resp), nil
|
||||
}
|
||||
|
||||
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out CardActionTriggerOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -27,21 +27,6 @@ func Keys() []event.KeyDefinition {
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
{
|
||||
Key: "card.action.trigger",
|
||||
DisplayName: "Card action",
|
||||
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
EventType: "card.action.trigger",
|
||||
SubscriptionType: event.SubTypeCallback,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||
},
|
||||
Process: processCardAction,
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
SingleConsumer: true,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
|
||||
@@ -7,7 +7,6 @@ package events
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||
// standard Lark V2 event envelope.
|
||||
type TaskUpdateUserAccessV2Data struct {
|
||||
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||
}
|
||||
|
||||
var taskUpdateUserAccessCommitTypes = []string{
|
||||
"task_create",
|
||||
"task_deleted",
|
||||
"task_summary_update",
|
||||
"task_desc_update",
|
||||
"task_assignees_update",
|
||||
"task_followers_update",
|
||||
"task_reminders_update",
|
||||
"task_start_due_update",
|
||||
"task_completed_update",
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||
|
||||
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"failed to subscribe task event",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
err error
|
||||
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.method = method
|
||||
s.path = path
|
||||
s.body = body
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||
rt := &stubAPIClient{}
|
||||
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||
}
|
||||
if rt.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||
}
|
||||
if rt.method != "POST" {
|
||||
t.Errorf("method = %q, want POST", rt.method)
|
||||
}
|
||||
if rt.path != taskSubscriptionPath {
|
||||
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||
}
|
||||
if rt.body != nil {
|
||||
t.Errorf("body = %#v, want nil", rt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||
rt := &stubAPIClient{err: wantErr}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||
cause := errors.New("connection reset")
|
||||
rt := &stubAPIClient{err: cause}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package task registers Task-domain EventKeys.
|
||||
package task
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||
|
||||
// Keys returns all Task-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeTaskUpdateUserAccessV2,
|
||||
DisplayName: "Task updated",
|
||||
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||
},
|
||||
PreConsume: taskSubscriptionPreConsume,
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||
SingleConsumer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Schema.Native is nil")
|
||||
}
|
||||
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Fatal("Native Task EventKey must not set Process")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume is nil")
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Fatal("SingleConsumer = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||
t.Errorf("Scopes = %#v", def.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
|
||||
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||
if got := taskGUID["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
|
||||
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||
items := eventTypes["items"].(map[string]interface{})
|
||||
rawEnum, ok := items["enum"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||
}
|
||||
got := make(map[string]bool, len(rawEnum))
|
||||
for _, v := range rawEnum {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||
if !got[want] {
|
||||
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeTaskUpdateUserAccessV2
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
12
go.mod
12
go.mod
@@ -27,6 +27,8 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/apache/arrow/go/v17 v17.0.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -42,13 +44,17 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.6 // indirect
|
||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -57,10 +63,16 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/smarty/assertions v1.15.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
|
||||
)
|
||||
|
||||
32
go.sum
32
go.sum
@@ -2,6 +2,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/apache/arrow/go/v17 v17.0.0 h1:RRR2bdqKcdbss9Gxy2NS/hK8i4LDMh23L6BbkN5+F54=
|
||||
github.com/apache/arrow/go/v17 v17.0.0/go.mod h1:jR7QHkODl15PfYyjM2nU+yTLScZ/qfj7OSUZmJ8putc=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
@@ -52,12 +54,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
@@ -74,11 +80,16 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -97,6 +108,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -133,14 +146,20 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -156,6 +175,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
@@ -169,10 +189,16 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ=
|
||||
gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -131,3 +131,31 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,31 +29,3 @@ func checkOwnerUID(path, label string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,22 +5,7 @@
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp command: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "exec provider command",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,7 @@ type EnumOption struct {
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum (with descriptions backfilled from options when the field carries
|
||||
// both forms), or from options when enum is absent — coerced to the canonical
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
@@ -123,14 +122,9 @@ func (f Field) EnumOptions() []EnumOption {
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
// key by raw literal so enum "1" and option 1 align across JSON types
|
||||
desc := make(map[string]string, len(f.Options))
|
||||
for _, o := range f.Options {
|
||||
desc[fmt.Sprintf("%v", o.Value)] = o.Description
|
||||
}
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
|
||||
out = append(out, EnumOption{Value: v})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
|
||||
@@ -80,39 +80,6 @@ func TestField_EnumOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
|
||||
// enum is the value set; descriptions backfilled from options, empty where absent
|
||||
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "6", Description: "subject"},
|
||||
}}
|
||||
want := []EnumOption{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "3", Description: ""},
|
||||
{Value: "4", Description: ""},
|
||||
{Value: "6", Description: "subject"},
|
||||
}
|
||||
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// enum values stored as strings match option values stored as numbers
|
||||
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
|
||||
{Value: 1, Description: "one"},
|
||||
{Value: 2, Description: "two"},
|
||||
}}
|
||||
wantI := []EnumOption{
|
||||
{Value: int64(1), Description: "one"},
|
||||
{Value: int64(2), Description: "two"},
|
||||
{Value: int64(10), Description: ""},
|
||||
}
|
||||
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
|
||||
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Enum_NumberAndBoolean(t *testing.T) {
|
||||
// number: string-stored floats coerced to float64 and numerically sorted
|
||||
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
|
||||
|
||||
@@ -472,18 +472,6 @@ func TestConvert_EnumDescriptions(t *testing.T) {
|
||||
if bare.EnumDescriptions != nil {
|
||||
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
|
||||
}
|
||||
|
||||
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
|
||||
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
}})
|
||||
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
|
||||
t.Errorf("both Enum = %v", both.Enum)
|
||||
}
|
||||
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
|
||||
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.57",
|
||||
"version": "1.0.56",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -179,10 +179,7 @@ fi
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
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"
|
||||
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
@@ -201,9 +198,8 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
|
||||
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"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
|
||||
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
|
||||
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
|
||||
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
|
||||
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
|
||||
@@ -214,8 +210,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
|
||||
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
|
||||
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
|
||||
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -85,9 +84,6 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
dry.Set("oversize_html", hits)
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
@@ -144,22 +140,18 @@ type appsHTMLPublishSpec struct {
|
||||
// per-environment .env.* files for every stage).
|
||||
const maxSensitiveListInError = 5
|
||||
|
||||
// truncatedJoin joins items with ", ", capping at max entries and appending
|
||||
// "(and N more)" for the remainder, so an inline error list stays readable when
|
||||
// a payload has many hits.
|
||||
func truncatedJoin(items []string, max int) string {
|
||||
if len(items) <= max {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
|
||||
}
|
||||
|
||||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||||
// contains credential files and --allow-sensitive was not set.
|
||||
func sensitiveCandidatesError(hits []string) error {
|
||||
var sample string
|
||||
if len(hits) <= maxSensitiveListInError {
|
||||
sample = strings.Join(hits, ", ")
|
||||
} else {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s",
|
||||
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
@@ -176,30 +168,6 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html(大小写不敏感)且单个 Size 超过
|
||||
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
|
||||
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
|
||||
var hits []string
|
||||
for _, c := range candidates {
|
||||
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
|
||||
hits = append(hits, c.RelPath)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
|
||||
func oversizeHTMLFilesError(hits []string) error {
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
|
||||
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
|
||||
}
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
@@ -222,9 +190,6 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
|
||||
@@ -503,82 +503,3 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
cands := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", Size: 50},
|
||||
{RelPath: "big.html", Size: 4096},
|
||||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||||
}
|
||||
hits := oversizeHTMLFiles(cands)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h == "huge.png" || h == "index.html" {
|
||||
t.Fatalf("unexpected hit %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,7 @@ var AppsInit = common.Shortcut{
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
|
||||
if existing != "" {
|
||||
dry.Set("app_id_mismatch", existing)
|
||||
}
|
||||
dry.Set("dir_error", e.Error())
|
||||
} else {
|
||||
dry.Set("already_initialized", true)
|
||||
}
|
||||
dry.Set("already_initialized", true)
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
@@ -206,61 +199,6 @@ func isAlreadyInitialized(dir string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id,用于判断目标目录是否同一个妙搭应用。
|
||||
// 返回 (appID, isSparkProject, err):
|
||||
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
|
||||
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
|
||||
// - 解析成功 → (trim 后的 app_id, true, nil)(app_id 缺失/为空时为 "")
|
||||
func readMetaAppID(dir string) (string, bool, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m struct {
|
||||
AppID string `json:"app_id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return strings.TrimSpace(m.AppID), true, nil
|
||||
}
|
||||
|
||||
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
|
||||
// - 不是妙搭工程(无 meta.json) → nil(交给 ensureEmptyDir 判空/非空)
|
||||
// - 是妙搭工程且 app_id 与 appID 一致 → nil(走已初始化短路,复用本地代码)
|
||||
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
|
||||
// - meta.json 损坏/不可读,无法确认 → 报错(fail closed),提示换目录
|
||||
//
|
||||
// 返回值 existing 是目录里已存在的 app_id(仅"已是另一个 app"的拒绝场景非空),供调用方在
|
||||
// dry-run 里回填 app_id_mismatch,避免二次读 meta.json。
|
||||
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
|
||||
existing, isSpark, readErr := readMetaAppID(dir)
|
||||
if readErr != nil {
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
|
||||
WithCause(readErr)
|
||||
}
|
||||
if !isSpark || existing == appID {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == "" {
|
||||
// meta 存在但缺 app_id:更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("remove the directory and re-run +init, or choose a different --dir")
|
||||
}
|
||||
return existing, appsValidationParamError("--dir",
|
||||
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
|
||||
dir, existing, appID).
|
||||
WithHint("choose a different --dir (or cd into the matching project) before running +init")
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
@@ -440,11 +378,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
|
||||
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
@@ -394,40 +394,6 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app_id must error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") {
|
||||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||||
@@ -1502,125 +1468,6 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetaAppID(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 不存在 meta.json → ("", false, nil)
|
||||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||||
}
|
||||
// 存在且有 app_id → (app_id, true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但 app_id 空 → ("", true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||||
t.Fatalf("no meta should pass: %v", err)
|
||||
}
|
||||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||||
}
|
||||
|
||||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||||
if errMismatch == nil {
|
||||
t.Fatal("different app should error")
|
||||
}
|
||||
if existing != "app_other" {
|
||||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(errMismatch, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||||
}
|
||||
if ve.Param != "--dir" {
|
||||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "different --dir") {
|
||||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||||
}
|
||||
|
||||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||||
if errEmpty == nil {
|
||||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||||
}
|
||||
if emptyExisting != "" {
|
||||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||||
}
|
||||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||||
}
|
||||
if strings.Contains(pEmpty.Message, "different app") {
|
||||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||||
}
|
||||
|
||||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||||
if errBad == nil {
|
||||
t.Fatal("corrupted meta should fail closed")
|
||||
}
|
||||
if badExisting != "" {
|
||||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||||
}
|
||||
pBad := requireAppsValidationProblem(t, errBad)
|
||||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||||
}
|
||||
var veBad *errs.ValidationError
|
||||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -33,7 +32,6 @@ import (
|
||||
)
|
||||
|
||||
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
|
||||
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
|
||||
|
||||
// gitCredentialIssueHint is the actionable next-step attached to a failed
|
||||
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
|
||||
@@ -304,12 +302,7 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
ctx = contextWithGitCredentialHelperShortcut(ctx)
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
@@ -321,13 +314,6 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
|
||||
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
|
||||
return ctx
|
||||
}
|
||||
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
|
||||
}
|
||||
|
||||
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
|
||||
if f == nil || f.IOStreams == nil {
|
||||
return nil
|
||||
|
||||
@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
|
||||
func TestFactoryIssuerBranches(t *testing.T) {
|
||||
factory, _, reg := newAppsExecuteFactory(t)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Unix()
|
||||
issueStub := &httpmock.Stub{
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
|
||||
Body: map[string]interface{}{
|
||||
@@ -836,8 +836,7 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
"StatusCode": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(issueStub)
|
||||
})
|
||||
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("factory issuer returned error: %v", err)
|
||||
@@ -845,12 +844,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
if issued.PAT != "pat-token" {
|
||||
t.Fatalf("PAT = %q", issued.PAT)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
|
||||
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
|
||||
}
|
||||
|
||||
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
|
||||
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
|
||||
@@ -887,20 +880,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
|
||||
got := contextWithGitCredentialHelperShortcut(ctx)
|
||||
|
||||
name, ok := cmdutil.ShortcutNameFromContext(got)
|
||||
if !ok || name != "apps:+git-credential-init" {
|
||||
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
|
||||
}
|
||||
executionID, ok := cmdutil.ExecutionIdFromContext(got)
|
||||
if !ok || executionID != "exec-existing" {
|
||||
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
|
||||
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))
|
||||
|
||||
@@ -1,545 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
|
||||
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepRecordList = "use +record-list to list records in the resolved table"
|
||||
titleResolveQueryMaxLen = 30
|
||||
)
|
||||
|
||||
var BaseURLResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+url-resolve",
|
||||
Description: "Resolve a Base-related URL into Base coordinates",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readURLResolveInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
|
||||
default:
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseURLResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTitleResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+title-resolve",
|
||||
Description: "Resolve a Base title or keyword through Drive search",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:docs:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readTitleResolveQuery(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/search/v2/doc_wiki/search").
|
||||
Body(buildTitleResolveSearchBody(query))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseTitleResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
|
||||
return "", resolveValidationError(
|
||||
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
|
||||
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
}
|
||||
return pickedValue, nil
|
||||
}
|
||||
|
||||
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
out, err := resolveWikiBaseURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
out, err := resolveRecordShareURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "form_share_url":
|
||||
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
|
||||
return nil
|
||||
case "view_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "dashboard_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "workspace_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "add_record_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
default:
|
||||
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func parseResolveURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func classifyBaseURL(u *url.URL) string {
|
||||
path := normalizeResolvePath(u.Path)
|
||||
switch {
|
||||
case pathSegmentExists(path, "/base/workspace/"):
|
||||
return "workspace_url"
|
||||
case pathSegmentExists(path, "/base/add/"):
|
||||
return "add_record_url"
|
||||
case pathSegmentExists(path, "/base/"):
|
||||
return "base_url"
|
||||
case pathSegmentExists(path, "/wiki/"):
|
||||
return "wiki_url"
|
||||
case pathSegmentExists(path, "/record/"):
|
||||
return "record_share_url"
|
||||
case pathSegmentExists(path, "/share/base/form/"):
|
||||
return "form_share_url"
|
||||
case pathSegmentExists(path, "/share/base/view/"):
|
||||
return "view_share_url"
|
||||
case pathSegmentExists(path, "/share/base/dashboard/"):
|
||||
return "dashboard_share_url"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
|
||||
if objType != "bitable" {
|
||||
return nil, resolveValidationError(
|
||||
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
|
||||
"Use the corresponding skill for that resource, or provide a Base URL.",
|
||||
)
|
||||
}
|
||||
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
|
||||
if baseToken == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"input_type": "wiki_url",
|
||||
"resource_type": "bitable",
|
||||
"wiki_node_token": token,
|
||||
"base_token": baseToken,
|
||||
"title": common.GetString(node, "title"),
|
||||
"hint": resolveHint("", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
shareToken := firstPathSegmentAfter(u.Path, "/record/")
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"input_type": "record_share_url",
|
||||
"resource_type": "bitable",
|
||||
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
|
||||
"base_token": common.GetString(data, "base_token"),
|
||||
"table_id": common.GetString(data, "table_id"),
|
||||
"record_id": common.GetString(data, "record_id"),
|
||||
}
|
||||
enrichRecordShareResolveHint(runtime, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveFormShareURL(u *url.URL) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"input_type": "form_share_url",
|
||||
"resource_type": "bitable_form",
|
||||
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return resolveValidationError(
|
||||
"No Base matched this title or keyword.",
|
||||
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
case 1:
|
||||
out := map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"title": candidates[0]["title"],
|
||||
"base_token": candidates[0]["base_token"],
|
||||
"url": candidates[0]["url"],
|
||||
"owner_name": candidates[0]["owner_name"],
|
||||
"update_time": candidates[0]["update_time"],
|
||||
"hint": resolveHint("", nil),
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"candidates": candidates,
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": baseTitleResolveHint,
|
||||
},
|
||||
}, nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
return
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
|
||||
hint := map[string]interface{}{}
|
||||
if baseToken != "" && tableID != "" && recordID != "" {
|
||||
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
|
||||
hint["record_data"] = formatResolvedRecordData(record)
|
||||
}
|
||||
}
|
||||
if baseToken != "" && tableID != "" {
|
||||
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
|
||||
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
|
||||
}
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, hint)
|
||||
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{"record_id_list": []string{recordID}}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
|
||||
return handleBaseAPIResult(result, err, "batch get records")
|
||||
}
|
||||
|
||||
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
|
||||
fieldIDs := common.GetSlice(record, "field_id_list")
|
||||
fieldNames := common.GetSlice(record, "fields")
|
||||
rows := common.GetSlice(record, "data")
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if len(rows) > 0 {
|
||||
if values, ok := rows[0].([]interface{}); ok {
|
||||
for i, value := range values {
|
||||
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
|
||||
if index < len(fieldIDs) {
|
||||
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
|
||||
return fieldID
|
||||
}
|
||||
}
|
||||
if index < len(fieldNames) {
|
||||
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
|
||||
return fieldName
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("field_%d", index+1)
|
||||
}
|
||||
|
||||
func recordShareNextStep(baseToken, tableID, recordID string) string {
|
||||
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
|
||||
hint := map[string]interface{}{}
|
||||
for key, value := range extra {
|
||||
hint[key] = value
|
||||
}
|
||||
if strings.TrimSpace(tableID) != "" {
|
||||
hint["next_step"] = nextStepRecordList
|
||||
} else {
|
||||
hint["next_step"] = nextStepBaseBlockList
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
func buildTitleResolveSearchBody(query string) map[string]interface{} {
|
||||
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
|
||||
return map[string]interface{}{
|
||||
"query": query,
|
||||
"page_size": 5,
|
||||
"doc_filter": filter,
|
||||
"wiki_filter": filter,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
|
||||
candidates := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
meta, _ := row["result_meta"].(map[string]interface{})
|
||||
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(common.GetString(meta, "token"))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(common.GetString(row, "title"))
|
||||
}
|
||||
candidates = append(candidates, map[string]interface{}{
|
||||
"title": title,
|
||||
"base_token": token,
|
||||
"url": common.GetString(meta, "url"),
|
||||
"owner_name": common.GetString(meta, "owner_name"),
|
||||
"update_time": common.GetString(meta, "update_time_iso"),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
|
||||
|
||||
func stripSearchHighlight(s string) string {
|
||||
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
func resolveValidationError(message, hint string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func normalizeResolvePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathSegmentExists(path, prefix string) bool {
|
||||
return firstPathSegmentAfter(path, prefix) != ""
|
||||
}
|
||||
|
||||
func firstPathSegmentAfter(path, prefix string) string {
|
||||
path = normalizeResolvePath(path)
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
func valueOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "an unknown resource type"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base only", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepBaseBlockList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not Base") {
|
||||
t.Fatalf("err=%v, want non-Base validation error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
recordData, _ := hint["record_data"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enrichment failure still returns meta", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["record_data"]; ok {
|
||||
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_share_token": shareToken,
|
||||
"base_token": baseToken,
|
||||
"table_id": tableID,
|
||||
"record_id": recordID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveFormShareURL(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantText string
|
||||
wantHint string
|
||||
}{
|
||||
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
|
||||
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
|
||||
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
|
||||
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
|
||||
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
|
||||
{"not url", "bas123", "only accepts full URLs", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", tc.rawURL, "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
|
||||
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("err=%v, want typed error with hint", err)
|
||||
}
|
||||
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
|
||||
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "original /base/{base_token}") {
|
||||
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
shortcut string
|
||||
definition common.Shortcut
|
||||
primaryFlag string
|
||||
primaryDesc string
|
||||
aliasFlags []string
|
||||
}{
|
||||
{
|
||||
shortcut: "+url-resolve",
|
||||
definition: BaseURLResolve,
|
||||
primaryFlag: "url",
|
||||
primaryDesc: "Base/Wiki/record-share URL to resolve",
|
||||
aliasFlags: []string{"query"},
|
||||
},
|
||||
{
|
||||
shortcut: "+title-resolve",
|
||||
definition: BaseTitleResolve,
|
||||
primaryFlag: "title",
|
||||
primaryDesc: "Base title keyword",
|
||||
aliasFlags: []string{"query", "url"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tc.definition.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
primary := cmd.Flags().Lookup(tc.primaryFlag)
|
||||
primaryUsage := ""
|
||||
if primary != nil {
|
||||
primaryUsage = primary.Usage
|
||||
}
|
||||
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
|
||||
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTitleResolve(t *testing.T) {
|
||||
t.Run("single result", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Sales <h>Pipeline</h>",
|
||||
"result_meta": map[string]interface{}{
|
||||
"doc_types": "BITABLE",
|
||||
"token": "bas123",
|
||||
"url": "https://example.larkoffice.com/base/bas123",
|
||||
"owner_name": "Alice",
|
||||
"update_time_iso": "2026-06-09T10:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple results and filter non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Doc hit",
|
||||
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>One</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>Two</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--url", "Base", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates=%#v, want 2", data["candidates"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub(nil))
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "missing", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
|
||||
t.Fatalf("err=%v, want no result validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query too long", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
|
||||
t.Fatalf("err=%v, want query length validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"res_units": items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"total": 2,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
|
||||
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{recordID},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_status"},
|
||||
"fields": []interface{}{"Name", "Status"},
|
||||
"data": []interface{}{[]interface{}{"Alice", "Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
|
||||
@@ -8,8 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseURLResolve,
|
||||
BaseTitleResolve,
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
|
||||
@@ -199,7 +199,16 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,9 @@ func TestNormalizeMCPToolResult(t *testing.T) {
|
||||
|
||||
got, err := normalizeMCPToolResult(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -49,7 +49,6 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -224,12 +223,6 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
|
||||
func (ctx *RuntimeContext) IntArray(name string) []int {
|
||||
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -1030,6 +1023,7 @@ func stripUTF8BOM(s string) string {
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
@@ -1049,14 +1043,12 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
// A process has a single stdin, so we reject a second Input flag
|
||||
// trying to use `-` after the first one has already consumed it.
|
||||
if rctx.stdinConsumed {
|
||||
if stdinUsed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
}
|
||||
rctx.stdinConsumed = true
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
@@ -1191,8 +1183,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "int_array":
|
||||
cmd.Flags().IntSlice(fl.Name, nil, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -22,7 +19,6 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for positional arg, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("expected positional args rejection message, got: %v", err)
|
||||
}
|
||||
@@ -40,7 +36,6 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple positional args, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
@@ -61,29 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagIntArray(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
var got []int
|
||||
shortcut := Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "capture screenshots",
|
||||
Flags: []Flag{
|
||||
{Name: "slide-number", Type: "int_array"},
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
|
||||
got = runtime.IntArray("slide-number")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("slide-number = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
@@ -198,7 +197,6 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
@@ -220,7 +218,6 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
@@ -241,7 +238,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
@@ -282,7 +279,6 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
@@ -295,7 +291,6 @@ func TestBotInfo_NilFunc(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support stdin") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support file input") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "cannot read file") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,8 +217,8 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
vErr := assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
// The hint must steer an AI agent to the fix (@file for the extra flags),
|
||||
// since `--a - <x --b - <y` is the exact misuse this guards against.
|
||||
|
||||
@@ -186,7 +186,9 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
@@ -206,7 +208,9 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
requireValidation(t, err, "invalid jq expression")
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
|
||||
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -85,7 +85,6 @@ type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type searchUserAPIItem struct {
|
||||
@@ -127,7 +126,6 @@ type searchUser struct {
|
||||
type searchUserResponse struct {
|
||||
Users []searchUser `json:"users"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchUser = common.Shortcut{
|
||||
@@ -191,7 +189,6 @@ var ContactSearchUser = common.Shortcut{
|
||||
Execute: executeSearchUser,
|
||||
}
|
||||
|
||||
// executeSearchUser dispatches contact search to single-query or fanout mode.
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
@@ -199,7 +196,6 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// executeSearchUserSingle performs one contact search and preserves server notices.
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
@@ -226,7 +222,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
if len(users) == 0 {
|
||||
|
||||
@@ -45,17 +45,22 @@ type fanoutResult struct {
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts one fanout request into either users or an error summary.
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
@@ -89,10 +94,9 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
// fanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
@@ -109,16 +113,17 @@ type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
@@ -137,7 +142,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
@@ -148,9 +152,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
|
||||
@@ -562,7 +562,6 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
func searchUserStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -570,7 +569,6 @@ func searchUserStub() *httpmock.Stub {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
@@ -592,7 +590,6 @@ func searchUserStub() *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
|
||||
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -617,7 +614,6 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
|
||||
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -635,9 +631,6 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
|
||||
@@ -1365,7 +1358,6 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
@@ -1407,7 +1399,6 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1415,7 +1406,6 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
@@ -1442,17 +1432,10 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q0 := queries[0].(map[string]interface{})
|
||||
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("queries[0].notice = %v", q0["notice"])
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
|
||||
@@ -55,7 +55,7 @@ var docCoverAllowedContentTypes = map[string]string{
|
||||
|
||||
var DocResourceDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+resource-download",
|
||||
Command: "resource-download",
|
||||
Description: "Download a document resource (type=cover downloads the cover image content)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
||||
@@ -154,7 +154,7 @@ var DocResourceDownload = common.Shortcut{
|
||||
|
||||
var DocResourceUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+resource-update",
|
||||
Command: "resource-update",
|
||||
Description: "Upload and update a document resource (type=cover)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
||||
@@ -256,7 +256,7 @@ var DocResourceUpdate = common.Shortcut{
|
||||
|
||||
var DocResourceDelete = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+resource-delete",
|
||||
Command: "resource-delete",
|
||||
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"+resource-download",
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover",
|
||||
@@ -95,7 +95,7 @@ func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T)
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"+resource-download",
|
||||
"resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover.png",
|
||||
@@ -116,7 +116,7 @@ func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"+resource-delete",
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -146,7 +146,7 @@ func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"+resource-delete",
|
||||
"resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -195,7 +195,7 @@ func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *tes
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -241,7 +241,7 @@ func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidate1",
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -258,7 +258,7 @@ func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverValidateRequired1",
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -273,7 +273,7 @@ func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"+resource-update",
|
||||
"resource-update",
|
||||
"--doc", "doxcnCoverURLValidate1",
|
||||
"--type", "cover",
|
||||
"--url", "https://127.0.0.1/cover.png",
|
||||
@@ -617,7 +617,7 @@ func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Command] = true
|
||||
}
|
||||
for _, want := range []string{"+resource-download", "+resource-update", "+resource-delete"} {
|
||||
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("Shortcuts() missing %s", want)
|
||||
}
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type imMarkdownContext struct {
|
||||
baseURL string
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
|
||||
|
||||
type imMarkdownTagHandler struct {
|
||||
closeRE *regexp.Regexp
|
||||
handle imMarkdownHandleFunc
|
||||
}
|
||||
|
||||
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
|
||||
imMarkdownHandlers[tag] = imMarkdownTagHandler{
|
||||
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
|
||||
handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
|
||||
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
|
||||
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
|
||||
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
|
||||
)
|
||||
|
||||
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
|
||||
|
||||
func init() {
|
||||
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
|
||||
for level := 1; level <= 9; level++ {
|
||||
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
|
||||
}
|
||||
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
|
||||
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
|
||||
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
|
||||
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
|
||||
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
|
||||
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
|
||||
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
|
||||
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
|
||||
registerIMMarkdownHandler("table", handleIMMarkdownTable)
|
||||
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
|
||||
registerIMMarkdownHandler("code", handleIMMarkdownCode)
|
||||
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
|
||||
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
|
||||
registerIMMarkdownHandler("img", handleIMMarkdownImage)
|
||||
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("source", handleIMMarkdownSource)
|
||||
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
|
||||
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
|
||||
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
|
||||
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
|
||||
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
|
||||
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
|
||||
}
|
||||
|
||||
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
|
||||
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
|
||||
}
|
||||
|
||||
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
|
||||
}
|
||||
|
||||
func newIMMarkdownContext(docInput string) imMarkdownContext {
|
||||
base := "https://larkoffice.com"
|
||||
raw := strings.TrimSpace(docInput)
|
||||
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
|
||||
base = extracted
|
||||
}
|
||||
return imMarkdownContext{baseURL: base}
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
|
||||
c.blockquoteDepth++
|
||||
return c
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) inBlockquote() bool {
|
||||
return c.blockquoteDepth > 0
|
||||
}
|
||||
|
||||
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
|
||||
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
|
||||
// intentionally strips host information, so it cannot serve this formatting path.
|
||||
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate := strings.Trim(raw[:idx], "/")
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
|
||||
return "https://" + u.Host, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset := 0; offset < len(content); {
|
||||
// Scan only to the next XML-like opening tag. Plain Markdown text between
|
||||
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
|
||||
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
start := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
|
||||
handler, ok := imMarkdownHandlers[tag]
|
||||
if !ok {
|
||||
// Unknown tags are left intact. im-markdown only downgrades tags with
|
||||
// explicit handlers so future server output does not get guessed at.
|
||||
out.WriteString(content[offset:openEnd])
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(content[offset:start])
|
||||
opening := content[start:openEnd]
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
out.WriteString(handler.handle(opening, "", attrs, imCtx))
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the handler's precompiled close regexp to find the matching end tag.
|
||||
// Depth tracking keeps nested same-name containers paired correctly.
|
||||
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
|
||||
if !found {
|
||||
// Malformed or truncated fragments are preserved as-is from the opening
|
||||
// tag onward; do not drop content when the XML-ish structure is incomplete.
|
||||
out.WriteString(content[start:])
|
||||
break
|
||||
}
|
||||
segment := content[start:closeEnd]
|
||||
inner := content[openEnd:closeStart]
|
||||
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
|
||||
offset = closeEnd
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseIMMarkdownAttrs(opening string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
|
||||
value := match[2]
|
||||
if value == "" {
|
||||
value = match[3]
|
||||
}
|
||||
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func isSelfClosingIMMarkdownTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
|
||||
}
|
||||
|
||||
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "# " + text
|
||||
}
|
||||
|
||||
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
|
||||
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
markdownLevel := level
|
||||
if markdownLevel > 6 {
|
||||
markdownLevel = 6
|
||||
}
|
||||
return strings.Repeat("#", markdownLevel) + " " + text
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if imCtx.inBlockquote() {
|
||||
return body + "\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, false, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, true, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
prefix := "-"
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
emoji := strings.TrimSpace(attrs["emoji"])
|
||||
if emoji != "" {
|
||||
if body == "" {
|
||||
body = emoji
|
||||
} else {
|
||||
body = emoji + " " + body
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return "---\n---"
|
||||
}
|
||||
return fmt.Sprintf("---\n%s\n---", body)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
lines[i] = ">"
|
||||
continue
|
||||
}
|
||||
lines[i] = "> " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return body + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
lang := strings.TrimSpace(attrs["lang"])
|
||||
code := strings.TrimSpace(inner)
|
||||
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
|
||||
code = match[1]
|
||||
}
|
||||
return imMarkdownFencedCode(html.UnescapeString(code), lang)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(markdownPlainText(inner))
|
||||
}
|
||||
|
||||
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
expr := strings.TrimSpace(markdownPlainText(inner))
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
|
||||
}
|
||||
|
||||
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return "---"
|
||||
}
|
||||
|
||||
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
|
||||
return fmt.Sprintf("", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
|
||||
}
|
||||
|
||||
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
name := strings.TrimSpace(attrs["name"])
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return imMarkdownInlineCode(name)
|
||||
}
|
||||
|
||||
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
for _, attrName := range attrNames {
|
||||
if strings.TrimSpace(attrs[attrName]) != "" {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
token := strings.TrimSpace(attrs["token"])
|
||||
if token == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
label := "sheet"
|
||||
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
|
||||
label = "sheet " + sheetID
|
||||
}
|
||||
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
|
||||
if href == "" {
|
||||
return name
|
||||
}
|
||||
return markdownLink(name, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "**" + body + "**"
|
||||
}
|
||||
|
||||
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "*" + body + "*"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "~~" + body + "~~"
|
||||
}
|
||||
|
||||
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
|
||||
if href == "" {
|
||||
return text
|
||||
}
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
|
||||
case "user":
|
||||
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
|
||||
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
|
||||
if userID == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
|
||||
case "doc":
|
||||
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(title, href)
|
||||
}
|
||||
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
|
||||
if docID == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
|
||||
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
|
||||
case "citation":
|
||||
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
|
||||
}
|
||||
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
|
||||
default:
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
// Rows and cells are matched with tag-depth tracking instead of non-greedy
|
||||
// regex captures. A table nested inside a cell can contain its own </tr> and
|
||||
// </td>; treating those as the outer row/cell boundary corrupts the table.
|
||||
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
|
||||
if len(rowBodies) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(rowBodies))
|
||||
for _, rowBody := range rowBodies {
|
||||
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
|
||||
if len(cellBodies) == 0 {
|
||||
continue
|
||||
}
|
||||
row := make([]string, 0, len(cellBodies))
|
||||
for _, cellBody := range cellBodies {
|
||||
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
cols := 0
|
||||
for _, row := range rows {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
|
||||
separator := make([]string, cols)
|
||||
for i := range separator {
|
||||
separator[i] = "-"
|
||||
}
|
||||
writeIMMarkdownTableRow(&out, separator)
|
||||
for _, row := range rows[1:] {
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// extractIMMarkdownElementBodies returns the inner content of each top-level
|
||||
// element matched by tagRE. tagRE must expose the optional closing slash as its
|
||||
// first capture group, matching the row/cell regexes above.
|
||||
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
|
||||
var bodies []string
|
||||
for offset := 0; offset < len(content); {
|
||||
loc := tagRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := content[openStart:openEnd]
|
||||
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
bodies = append(bodies, "")
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
bodies = append(bodies, content[openEnd:closeStart])
|
||||
offset = closeEnd
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func normalizeIMMarkdownTableCell(cell string) string {
|
||||
const brPlaceholder = "\x00BR\x00"
|
||||
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
|
||||
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
|
||||
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
|
||||
if name == "at" {
|
||||
return tag
|
||||
}
|
||||
return ""
|
||||
})
|
||||
cell = html.UnescapeString(cell)
|
||||
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
|
||||
cell = strings.ReplaceAll(cell, " \n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "\n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "|", `\|`)
|
||||
lines := strings.Fields(cell)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
|
||||
out.WriteString("| ")
|
||||
out.WriteString(strings.Join(row, " | "))
|
||||
out.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func padIMMarkdownTableRow(row []string, cols int) []string {
|
||||
if len(row) >= cols {
|
||||
return row
|
||||
}
|
||||
padded := make([]string, cols)
|
||||
copy(padded, row)
|
||||
return padded
|
||||
}
|
||||
|
||||
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset, index := 0, 1; offset < len(inner); {
|
||||
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := inner[openStart:openEnd]
|
||||
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
|
||||
if body != "" {
|
||||
prefix := "-"
|
||||
if ordered {
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
} else {
|
||||
prefix = fmt.Sprintf("%d.", index)
|
||||
}
|
||||
index++
|
||||
}
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(" ")
|
||||
out.WriteString(indentIMMarkdownListContinuation(body))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
offset = closeEnd
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func indentIMMarkdownListContinuation(body string) string {
|
||||
return strings.ReplaceAll(body, "\n", "\n ")
|
||||
}
|
||||
|
||||
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
|
||||
match := imMarkdownLinkRE.FindStringSubmatch(inner)
|
||||
if match == nil {
|
||||
return "", "", false
|
||||
}
|
||||
href := match[1]
|
||||
if href == "" {
|
||||
href = match[2]
|
||||
}
|
||||
text := strings.TrimSpace(markdownPlainText(match[3]))
|
||||
if text == "" {
|
||||
text = href
|
||||
}
|
||||
return text, html.UnescapeString(href), true
|
||||
}
|
||||
|
||||
func markdownPlainText(s string) string {
|
||||
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
|
||||
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func markdownLinkLabelText(s string) string {
|
||||
text := markdownPlainText(s)
|
||||
if !strings.Contains(text, "---") {
|
||||
return text
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
kept := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
func markdownLink(text, href string) string {
|
||||
cleanHref := strings.TrimSpace(href)
|
||||
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkText(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, `[`, `\[`)
|
||||
text = strings.ReplaceAll(text, `]`, `\]`)
|
||||
return text
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkDestination(href string) string {
|
||||
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
|
||||
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
|
||||
// characters that can terminate or split the Markdown link destination.
|
||||
var out strings.Builder
|
||||
out.Grow(len(href))
|
||||
for i := 0; i < len(href); {
|
||||
if href[i] == '%' {
|
||||
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
|
||||
out.WriteString(href[i : i+3])
|
||||
i += 3
|
||||
} else {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if href[i] < utf8.RuneSelf {
|
||||
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
} else {
|
||||
out.WriteByte(href[i])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(href[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
for _, b := range []byte(href[i : i+size]) {
|
||||
writePercentEncodedByte(&out, b)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
|
||||
if b <= ' ' || b >= 0x7f {
|
||||
return true
|
||||
}
|
||||
switch b {
|
||||
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writePercentEncodedByte(out *strings.Builder, b byte) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hex[b>>4])
|
||||
out.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
|
||||
func isHexDigit(b byte) bool {
|
||||
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
|
||||
}
|
||||
|
||||
func imMarkdownInlineCode(s string) string {
|
||||
maxRun := 0
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r == '`' {
|
||||
run++
|
||||
if run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
fence := strings.Repeat("`", maxRun+1)
|
||||
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
|
||||
return fence + " " + s + " " + fence
|
||||
}
|
||||
return fence + s + fence
|
||||
}
|
||||
|
||||
func imMarkdownFencedCode(code, lang string) string {
|
||||
maxRun := 0
|
||||
for _, line := range strings.Split(code, "\n") {
|
||||
if run := leadingBacktickRun(line); run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
}
|
||||
fenceLen := maxRun + 1
|
||||
if fenceLen < 3 {
|
||||
fenceLen = 3
|
||||
}
|
||||
fence := strings.Repeat("`", fenceLen)
|
||||
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
|
||||
}
|
||||
|
||||
func leadingBacktickRun(s string) int {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r != '`' {
|
||||
break
|
||||
}
|
||||
run++
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
@@ -72,9 +72,6 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
if isIMMarkdownFetch(runtime) {
|
||||
applyFetchIMMarkdown(data, runtime.Str("doc"))
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
@@ -88,7 +85,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
"format": runtime.Str("doc-format"),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
@@ -125,14 +122,6 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func effectiveFetchFormat(runtime *common.RuntimeContext) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
if format == "im-markdown" {
|
||||
return "markdown"
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
if runtime.Changed("lang") {
|
||||
return strings.TrimSpace(runtime.Str("lang"))
|
||||
|
||||
@@ -6,12 +6,9 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -107,369 +104,6 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "revision-id", "42")
|
||||
mustSetFetchFlag(t, runtime, "detail", "full")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["revision_id"]; got != 42 {
|
||||
t.Fatalf("revision_id = %#v, want 42", got)
|
||||
}
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
"export_style_attrs": true,
|
||||
"export_cite_extra_data": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "detail", "with-ids")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "scope", "section")
|
||||
mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
}
|
||||
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read_option = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full omits read option",
|
||||
setFlags: map[string]string{
|
||||
"scope": "full",
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "outline with max depth",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
"max-depth": "3",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "outline",
|
||||
"max_depth": "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with block ids and context",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"end-block-id": "blk_end",
|
||||
"context-before": "2",
|
||||
"context-after": "1",
|
||||
"max-depth": "0",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "blk_start",
|
||||
"end_block_id": "blk_end",
|
||||
"context_before": "2",
|
||||
"context_after": "1",
|
||||
"max_depth": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with query",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context-before": "1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context_before": "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section keeps unlimited depth omitted",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
"max-depth": "-1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "negative context before",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-before": "-1",
|
||||
},
|
||||
wantParam: "--context-before",
|
||||
},
|
||||
{
|
||||
name: "negative context after",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-after": "-1",
|
||||
},
|
||||
wantParam: "--context-after",
|
||||
},
|
||||
{
|
||||
name: "max depth below unlimited sentinel",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"max-depth": "-2",
|
||||
},
|
||||
wantParam: "--max-depth",
|
||||
},
|
||||
{
|
||||
name: "range needs boundary",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
},
|
||||
wantParams: []string{
|
||||
"--start-block-id",
|
||||
"--end-block-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword needs keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
},
|
||||
wantParam: "--keyword",
|
||||
},
|
||||
{
|
||||
name: "section needs start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
},
|
||||
wantParam: "--start-block-id",
|
||||
},
|
||||
{
|
||||
name: "unknown scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
err := validateReadModeFlags(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateReadModeFlags() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "outline",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with end block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|缺陷",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section with start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
t.Fatalf("validateReadModeFlags() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "invalid doc",
|
||||
setFlags: map[string]string{
|
||||
"doc": "https://example.com/sheets/sht_token",
|
||||
},
|
||||
wantParam: "--doc",
|
||||
},
|
||||
{
|
||||
name: "invalid scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateFetchV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateFetchV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "xml format",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "xml",
|
||||
"detail": "full",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "markdown simple detail",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": "simple",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
|
||||
t.Fatalf("warning = %q, want empty", got)
|
||||
}
|
||||
if _, ok := data["warnings"]; ok {
|
||||
t.Fatalf("unexpected warnings: %#v", data["warnings"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -507,54 +141,36 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "im-markdown",
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
if got, want := dry.API[0].Body["format"], "markdown"; got != want {
|
||||
t.Fatalf("dry-run format = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, format := range []string{"markdown", "im-markdown"} {
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(format+"/"+detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": format,
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
})
|
||||
}
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,107 +261,6 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999999,
|
||||
"msg": "fetch failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchAPIError",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mountAndRunDocs() succeeded, want API error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if p.Code != 999999 {
|
||||
t.Errorf("code = %d, want 999999", p.Code)
|
||||
}
|
||||
if p.Message != "fetch failed" {
|
||||
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
|
||||
}
|
||||
if cause := errors.Unwrap(err); cause != nil {
|
||||
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdown",
|
||||
"revision_id": float64(1),
|
||||
"content": strings.Join([]string{
|
||||
`<title>Doc Title</title>`,
|
||||
`<callout emoji="💡">Read **this**.</callout>`,
|
||||
`<bookmark name="Example" href="https://example.com"></bookmark>`,
|
||||
}, "\n\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdown",
|
||||
"--doc-format", "im-markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
for _, want := range []string{
|
||||
"# Doc Title",
|
||||
"---\n💡 Read **this**.\n---",
|
||||
"[Example](https://example.com)",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("converted content missing %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "<title>") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") {
|
||||
t.Fatalf("converted content still contains downgraded XML tags:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -776,7 +291,6 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected v2-only validation error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
|
||||
for _, want := range tt.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
@@ -802,14 +316,6 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -74,9 +74,6 @@ var DocsSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
if len(normalizedItems) == 0 {
|
||||
|
||||
@@ -7,48 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
|
||||
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DocsSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
|
||||
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"drive:drive.metadata:readonly",
|
||||
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
@@ -148,17 +148,6 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if docRef.Kind == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
_, err := parseBaseCommentAnchor(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
@@ -199,7 +188,7 @@ var DriveAddComment = common.Shortcut{
|
||||
return validateFileCommentMode(mode, "")
|
||||
}
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -226,23 +215,6 @@ var DriveAddComment = common.Shortcut{
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
|
||||
if resolvedKind == "base" {
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
desc := "1-step request: create base(bitable) record-local comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
@@ -380,14 +352,6 @@ var DriveAddComment = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "base" {
|
||||
return executeBaseComment(runtime, resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
FileToken: docRef.Token,
|
||||
FileType: "base",
|
||||
ResolvedBy: "base",
|
||||
})
|
||||
}
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
@@ -411,9 +375,6 @@ var DriveAddComment = common.Shortcut{
|
||||
if target.FileType == "slides" {
|
||||
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
|
||||
}
|
||||
if target.FileType == "base" {
|
||||
return executeBaseComment(runtime, target)
|
||||
}
|
||||
if target.FileType == "file" {
|
||||
return executeFileComment(runtime, target)
|
||||
}
|
||||
@@ -521,12 +482,6 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/base/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/bitable/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/file/"); ok {
|
||||
return commentDocRef{Kind: "file", Token: token}, nil
|
||||
}
|
||||
@@ -540,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
|
||||
@@ -549,10 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
|
||||
}
|
||||
if docType == "bitable" || docType == "base" {
|
||||
return commentDocRef{Kind: "base", Token: raw}, nil
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
@@ -563,11 +515,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
|
||||
if mode == commentModeLocal {
|
||||
switch docRef.Kind {
|
||||
case "doc":
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
case "file":
|
||||
if err := validateFileCommentMode(mode, ""); err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
@@ -605,22 +557,6 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if objType == "bitable" || objType == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "base",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -656,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -851,12 +787,6 @@ type sheetAnchor struct {
|
||||
Row int
|
||||
}
|
||||
|
||||
type baseAnchor struct {
|
||||
BlockID string
|
||||
BaseRecordID string
|
||||
BaseViewID string
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
@@ -883,18 +813,6 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"file_type": "bitable",
|
||||
"reply_elements": replyElements,
|
||||
"anchor": map[string]interface{}{
|
||||
"block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func anchorBlockIDForDryRun(blockID string) string {
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
return strings.TrimSpace(blockID)
|
||||
@@ -902,26 +820,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
|
||||
}
|
||||
return parseBaseBlockRef(blockID)
|
||||
}
|
||||
|
||||
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
|
||||
parts := strings.Split(strings.TrimSpace(blockID), "!")
|
||||
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
|
||||
}
|
||||
return baseAnchor{
|
||||
BlockID: strings.TrimSpace(parts[0]),
|
||||
BaseRecordID: strings.TrimSpace(parts[1]),
|
||||
BaseViewID: strings.TrimSpace(parts[2]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSlidesBlockRef(blockID string) (string, string, error) {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
@@ -1132,53 +1030,6 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
|
||||
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": "bitable",
|
||||
"resolved_by": target.ResolvedBy,
|
||||
"comment_mode": "base_record",
|
||||
"base_block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
}
|
||||
if commentID := data["comment_id"]; commentID != nil {
|
||||
out["comment_id"] = commentID
|
||||
}
|
||||
if replyID := data["reply_id"]; replyID != nil {
|
||||
out["reply_id"] = replyID
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
|
||||
@@ -133,20 +133,6 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "fileToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type bitable",
|
||||
input: "baseToken",
|
||||
docType: "bitable",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type base alias",
|
||||
input: "baseToken",
|
||||
docType: "base",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
@@ -170,18 +156,6 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "boxcn123",
|
||||
},
|
||||
{
|
||||
name: "base url",
|
||||
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken123",
|
||||
},
|
||||
{
|
||||
name: "bitable url",
|
||||
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken456",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-a-doc",
|
||||
@@ -752,35 +726,6 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "base comment"},
|
||||
}
|
||||
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
|
||||
BlockID: "tbl9mp6fj9kDKHQV",
|
||||
BaseRecordID: "recBIBgGmb",
|
||||
BaseViewID: "vewc46MG1R",
|
||||
})
|
||||
|
||||
if got["file_type"] != "bitable" {
|
||||
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["base_record_id"] != "recBIBgGmb" {
|
||||
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
|
||||
}
|
||||
if anchor["base_view_id"] != "vewc46MG1R" {
|
||||
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
@@ -1040,78 +985,6 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
|
||||
cases := []string{
|
||||
"tbl9mp6fj9kDKHQV",
|
||||
"tbl9mp6fj9kDKHQV!recBIBgGmb",
|
||||
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
|
||||
}
|
||||
for _, blockID := range cases {
|
||||
t.Run(blockID, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", blockID,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
|
||||
t.Fatalf("expected block-id format error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slides comment execute tests ────────────────────────────────────────────
|
||||
|
||||
func TestSlidesCommentExecuteSuccess(t *testing.T) {
|
||||
@@ -1322,87 +1195,6 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"comment_id": "baseComment123",
|
||||
"reply_id": "baseReply123",
|
||||
"created_at": 1700000000,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"请看这条记录"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
|
||||
}
|
||||
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
|
||||
t.Fatalf("request file_type = %q, want bitable", got)
|
||||
}
|
||||
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
|
||||
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
|
||||
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
|
||||
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteBareToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "baseBareComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "baseBareToken",
|
||||
"--type", "bitable",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "baseBareComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1641,40 +1433,6 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunBaseDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "record-local comment") {
|
||||
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
|
||||
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSlides(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1878,92 +1636,25 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseComment(t *testing.T) {
|
||||
for _, objType := range []string{"bitable", "base"} {
|
||||
t.Run(objType, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiBaseComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
|
||||
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2044,7 +1735,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -16,24 +15,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wrapExportContextErr converts a context cancellation / deadline error into a
|
||||
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
|
||||
// preserved for errors.Is) instead of an untyped context.Canceled /
|
||||
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
|
||||
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
|
||||
func wrapExportContextErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
msg := "drive +export polling cancelled: %s"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
msg = "drive +export polling deadline exceeded: %s"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
|
||||
}
|
||||
|
||||
// DriveExport exports Drive-native documents to local files and falls back to
|
||||
// a follow-up command when the async export task does not finish in time.
|
||||
var DriveExport = common.Shortcut{
|
||||
@@ -59,7 +40,7 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateExport(exportParamsFromFlags(runtime))
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
@@ -117,11 +98,8 @@ func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
}
|
||||
}
|
||||
|
||||
// validateExport runs the CLI-level export constraint checks. Unexported because
|
||||
// only drive +export's Validate consumes it directly; sheets +workbook-export
|
||||
// reuses RunExport / PlanExportDryRun but inlines its own (sheet-specific)
|
||||
// validation, so there is no cross-package call site to keep exported.
|
||||
func validateExport(p ExportParams) error {
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
@@ -248,12 +226,12 @@ func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportPara
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return wrapExportContextErr(ctx.Err())
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return wrapExportContextErr(err)
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
|
||||
@@ -5,7 +5,6 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -1101,37 +1100,3 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
|
||||
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
|
||||
// directly so an untyped context.Canceled would escape as a plain string at
|
||||
// the command layer, bypassing the typed-error contract.
|
||||
func TestWrapExportContextErr(t *testing.T) {
|
||||
if err := wrapExportContextErr(nil); err != nil {
|
||||
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
|
||||
}
|
||||
|
||||
cancelled := wrapExportContextErr(context.Canceled)
|
||||
var netErrCancel *errs.NetworkError
|
||||
if !errors.As(cancelled, &netErrCancel) {
|
||||
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
|
||||
}
|
||||
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(cancelled, context.Canceled) {
|
||||
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
|
||||
}
|
||||
|
||||
deadline := wrapExportContextErr(context.DeadlineExceeded)
|
||||
var netErrDeadline *errs.NetworkError
|
||||
if !errors.As(deadline, &netErrDeadline) {
|
||||
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
|
||||
}
|
||||
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
if !errors.Is(deadline, context.DeadlineExceeded) {
|
||||
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +149,6 @@ var DriveSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
renderDriveSearchTable(w, data, normalizedItems)
|
||||
|
||||
@@ -14,49 +14,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
|
||||
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DriveSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
|
||||
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
|
||||
// narrows --opened-since / --opened-until and generates the multi-slice notice.
|
||||
func TestClampOpenedTimeWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
|
||||
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
|
||||
// command whose flags are populated from the provided string and bool maps,
|
||||
// for unit-testing shortcut bodies, validators, and dry-run shapes.
|
||||
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -57,38 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
|
||||
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, val := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
|
||||
// newMessagesSearchTestRuntimeContext is the messages-search variant of
|
||||
// newTestRuntimeContext: registers the search-specific --page-size flag
|
||||
// before applying caller-provided values.
|
||||
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -258,7 +231,6 @@ func TestIsMediaKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutValidateBranches covers direct shortcut validation branches.
|
||||
func TestShortcutValidateBranches(t *testing.T) {
|
||||
|
||||
t.Run("ImChatCreate valid", func(t *testing.T) {
|
||||
@@ -325,7 +297,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "ok",
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
@@ -335,13 +307,12 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 81),
|
||||
"page-size": "20",
|
||||
t.Run("ImChatSearch query too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 65),
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -469,29 +440,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend audio rejects non-opus local file", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"audio": "./voice.mp3",
|
||||
}, nil)
|
||||
err := ImMessagesSend.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend audio accepts opus and ogg local files", func(t *testing.T) {
|
||||
for _, audio := range []string{"./voice.opus", "./voice.ogg"} {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"audio": audio,
|
||||
}, nil)
|
||||
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSend.Validate(%q) unexpected error = %v", audio, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
@@ -515,17 +463,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply audio rejects non-opus local file", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"audio": "./voice.mp3",
|
||||
}, nil)
|
||||
err := ImMessagesReply.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "bad_thread",
|
||||
@@ -670,7 +607,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
|
||||
func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
t.Run("default single page", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
|
||||
@@ -714,7 +650,8 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
|
||||
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
|
||||
// produces the expected API path, query parameters, and request body.
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
@@ -737,19 +674,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"page-size": "50",
|
||||
"page-token": "next_page",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
}, map[string]bool{
|
||||
"exclude-muted": true,
|
||||
|
||||
@@ -1048,42 +1048,6 @@ func detectIMFileType(filePath string) string {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
audioMessageInputDesc = "audio file key (file_xxx), URL, or cwd-relative local path for a voice message (absolute paths and .. are rejected); local paths and URLs must be Opus (.opus or Ogg Opus .ogg). For mp3/wav, convert to .opus first, or use --file to send as an attachment"
|
||||
audioMessageHint = "Convert non-Opus audio to .opus and use --audio for a voice message, for example: ffmpeg -i input.mp3 -acodec libopus -ac 1 -ar 16000 output.opus. To send the original mp3/wav as an attachment, use --file instead."
|
||||
)
|
||||
|
||||
func validateAudioMessageInput(flagName, value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || isMediaKey(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := audioInputExt(value)
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
if ext == ".opus" || ext == ".ogg" {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s supports only Opus audio files for audio messages, such as .opus files or Ogg Opus (.ogg) files",
|
||||
flagName,
|
||||
).WithParam(flagName).WithHint("%s", audioMessageHint)
|
||||
}
|
||||
|
||||
func audioInputExt(value string) string {
|
||||
if isURL(value) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(path.Ext(parsed.Path))
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(value))
|
||||
}
|
||||
|
||||
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
||||
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -51,56 +50,6 @@ func TestDetectIMFileType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAudioMessageInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty", value: ""},
|
||||
{name: "existing file key", value: "file_abc"},
|
||||
{name: "opus file", value: "./voice.opus"},
|
||||
{name: "ogg opus file", value: "./voice.ogg"},
|
||||
{name: "uppercase opus", value: "./VOICE.OPUS"},
|
||||
{name: "mp3 local file", value: "./voice.mp3", wantErr: true},
|
||||
{name: "wav local file", value: "./voice.wav", wantErr: true},
|
||||
{name: "extensionless local path", value: "./voice"},
|
||||
{name: "opus url", value: "https://example.com/voice.opus?download=1"},
|
||||
{name: "ogg url", value: "https://example.com/voice.ogg?download=1"},
|
||||
{name: "mp3 url", value: "https://example.com/voice.mp3?download=1", wantErr: true},
|
||||
{name: "extensionless url", value: "https://example.com/download?id=1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateAudioMessageInput("--audio", tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("validateAudioMessageInput(%q) error = %v", tt.value, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("validateAudioMessageInput(%q) error is not typed: %v", tt.value, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("ProblemOf(%q) = category %q subtype %q", tt.value, p.Category, p.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--audio" {
|
||||
t.Fatalf("validateAudioMessageInput(%q) param = %q, want --audio", tt.value, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "use --file") || !strings.Contains(p.Hint, "ffmpeg") {
|
||||
t.Fatalf("validateAudioMessageInput(%q) hint = %q, want --file and ffmpeg guidance", tt.value, p.Hint)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("validateAudioMessageInput(%q) unexpected error = %v", tt.value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitCSV covers the shared helper that replaced the three identical functions
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "query", Desc: "search keyword (max 64 chars)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Params(params).
|
||||
Body(body)
|
||||
},
|
||||
// Validate enforces query/member-ids presence, search-types
|
||||
// Validate enforces query/member-ids presence, --query rune cap, search-types
|
||||
// enum, --member-ids count and format, and --page-size bounds.
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
query := runtime.Str("query")
|
||||
@@ -58,6 +58,9 @@ var ImChatSearch = common.Shortcut{
|
||||
if query == "" && memberIDs == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
}
|
||||
if query != "" && len([]rune(query)) > 64 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
|
||||
}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
allowed := map[string]struct{}{
|
||||
"private": {},
|
||||
@@ -148,9 +151,6 @@ var ImChatSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if notice, _ := resData["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
},
|
||||
@@ -100,9 +100,6 @@ var ImMessagesReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if messageId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
|
||||
|
||||
@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,9 +103,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
})
|
||||
@@ -134,9 +131,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"page_token": nextPageToken,
|
||||
"note": "failed to fetch message details, returning ID list only",
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
|
||||
for _, id := range messageIds {
|
||||
@@ -212,9 +206,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
@@ -386,7 +377,6 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
}, nil
|
||||
}
|
||||
|
||||
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
|
||||
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
|
||||
autoPaginate = runtime.Bool("page-all")
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
@@ -402,8 +392,7 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
|
||||
return autoPaginate, pageLimit
|
||||
}
|
||||
|
||||
// searchMessages fetches message search pages and returns the first server notice.
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
pageToken := ""
|
||||
if tokens := req.params["page_token"]; len(tokens) > 0 {
|
||||
@@ -421,7 +410,6 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
lastPageToken string
|
||||
truncatedByLimit bool
|
||||
pageCount int
|
||||
notice string
|
||||
)
|
||||
|
||||
for {
|
||||
@@ -435,12 +423,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
|
||||
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
if err != nil {
|
||||
return nil, false, "", false, pageLimit, "", err
|
||||
return nil, false, "", false, pageLimit, err
|
||||
}
|
||||
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
items, _ := searchData["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
|
||||
@@ -456,10 +441,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
pageToken = lastPageToken
|
||||
}
|
||||
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
|
||||
}
|
||||
|
||||
// batchMGetMessages fetches message details in API-sized batches.
|
||||
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||
var items []interface{}
|
||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
||||
@@ -473,7 +457,6 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
|
||||
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
// Best-effort: a failed chunk only loses its own entries.
|
||||
@@ -483,7 +466,6 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
|
||||
return chatContexts
|
||||
}
|
||||
|
||||
// chunkStrings splits a string slice into fixed-size batches.
|
||||
func chunkStrings(items []string, chunkSize int) [][]string {
|
||||
if len(items) == 0 || chunkSize <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -37,7 +37,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatFlag := runtime.Str("chat-id")
|
||||
@@ -112,9 +112,6 @@ var ImMessagesSend = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
|
||||
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
longQuery := strings.Repeat("q", 81)
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
if got, _ := body["query"].(string); got != longQuery {
|
||||
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImChatSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
|
||||
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
runtime := newMessagesSearchRuntime(t, map[string]string{
|
||||
"query": "incident",
|
||||
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
|
||||
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("query", query); err != nil {
|
||||
t.Fatalf("Flags().Set(query) error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
|
||||
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("envelope data missing or wrong type: %#v", env)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -159,7 +159,6 @@ var MailTriage = common.Shortcut{
|
||||
var messages []map[string]interface{}
|
||||
var hasMore bool
|
||||
var nextPageToken string
|
||||
var notice string
|
||||
|
||||
useSearch, err := resolveTriagePath(parsed, query, filter)
|
||||
if err != nil {
|
||||
@@ -190,9 +189,6 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
|
||||
messages = append(messages, pageMessages...)
|
||||
pageHasMore, _ := searchData["has_more"].(bool)
|
||||
@@ -286,14 +282,8 @@ var MailTriage = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
return nil
|
||||
@@ -770,7 +760,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
}
|
||||
} else {
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
}
|
||||
} else if folderFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -780,7 +776,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderFromFilter
|
||||
}
|
||||
} else {
|
||||
params["folder_id"] = folderFromFilter
|
||||
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,7 +801,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelIDFromFilter
|
||||
}
|
||||
} else {
|
||||
params["label_id"] = labelIDFromFilter
|
||||
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
}
|
||||
} else if labelFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -809,7 +817,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelFromFilter
|
||||
}
|
||||
} else {
|
||||
params["label_id"] = labelFromFilter
|
||||
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -975,11 +974,7 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
|
||||
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "sent"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
got, err := buildListParams(rt, "me", f, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,30 +983,10 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "team-folder"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["folder_id"] != "team-folder" {
|
||||
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "flagged"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
got, err := buildListParams(rt, "me", f, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1020,25 +995,6 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "custom-label"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := got["folder_id"]; ok {
|
||||
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
|
||||
}
|
||||
if got["label_id"] != "custom-label" {
|
||||
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSearchParams additional coverage ---
|
||||
|
||||
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
|
||||
@@ -1522,16 +1478,14 @@ func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// --- mailbox_id preservation tests ---
|
||||
|
||||
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
|
||||
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
wantNotice string
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "list json default mailbox",
|
||||
@@ -1568,10 +1522,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageSearchStub(reg, mailbox, []interface{}{
|
||||
mailTriageSearchItem("search_pub_001", "Shared search"),
|
||||
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
|
||||
}, false, "")
|
||||
},
|
||||
wantCount: 1,
|
||||
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list json keeps top-level mailbox",
|
||||
@@ -1606,9 +1559,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
if data["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
|
||||
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != tt.wantCount {
|
||||
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
|
||||
@@ -1622,7 +1572,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
|
||||
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
@@ -1655,7 +1604,6 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
|
||||
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1706,33 +1654,6 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
|
||||
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageSearchStub(reg, "me", []interface{}{
|
||||
mailTriageSearchItem("msg_search_notice", "Search notice result"),
|
||||
}, false, "", notice)
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--query", strings.Repeat("q", 81),
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
|
||||
t.Fatalf("stdout should contain table row, got:\n%s", out)
|
||||
}
|
||||
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
|
||||
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
|
||||
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
@@ -1742,7 +1663,6 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
|
||||
return data
|
||||
}
|
||||
|
||||
// mailTriageMessagesFromOutput extracts triage messages as object maps.
|
||||
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
rawMessages, ok := data["messages"].([]interface{})
|
||||
@@ -1795,8 +1715,7 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
@@ -1804,9 +1723,6 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
if len(notices) > 0 && notices[0] != "" {
|
||||
data["notice"] = notices[0]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: mailboxPath(mailbox, "search"),
|
||||
@@ -1835,137 +1751,3 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
|
||||
// mailbox folders list API. Because it is non-reusable, any second hit returns
|
||||
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
|
||||
// use to prove resolveListFilter runs once and buildListParams does NOT
|
||||
// re-resolve. folderID/folderName is the single custom folder the API reports.
|
||||
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: mailboxPath(mailbox, "folders"),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": folderID,
|
||||
"name": folderName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageListPageStub registers one page of the messages list API,
|
||||
// disambiguated from sibling pages by a URL substring unique to that page
|
||||
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
|
||||
// must NOT depend on query-param ordering: map iteration makes param order
|
||||
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
|
||||
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
|
||||
// so reg.Verify catches under- or over-consumption.
|
||||
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: urlSubstring,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
|
||||
// for the bug where buildListParams re-called resolveFolderID on every list
|
||||
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
|
||||
// easily tripping rate limits.
|
||||
//
|
||||
// Setup: a custom folder filter that forces resolveListFilter to hit the
|
||||
// folders list API once (to map folder name "team-folder" to folder_id), then two
|
||||
// messages-list pages. The folders list stub is non-reusable, so if
|
||||
// buildListParams re-resolves, the second hit fails with "no stub". The
|
||||
// messages-list stubs are page-specific (disambiguated by page_size in the
|
||||
// URL), so both pages are served and Verify asserts each fired exactly once.
|
||||
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
// listMailboxFolders (called once by resolveListFilter) gates on the
|
||||
// mail:user_mailbox.folder:read scope, which the default test token does
|
||||
// not carry. Re-store the token with that scope appended so the folders
|
||||
// API call is actually exercised (and thus the non-reusable folders stub
|
||||
// is the load-bearing "exactly once" assertion).
|
||||
const folderScope = "mail:user_mailbox.folder:read"
|
||||
cfg := mailTestConfig()
|
||||
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
|
||||
if !strings.Contains(stored.Scope, folderScope) {
|
||||
stored.Scope = stored.Scope + " " + folderScope
|
||||
if err := auth.SetStoredToken(stored); err != nil {
|
||||
t.Fatalf("re-store token with folder scope: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
mailbox = "me"
|
||||
folderName = "team-folder"
|
||||
folderID = "fld_custom_team"
|
||||
page2Token = "tok_page2"
|
||||
)
|
||||
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
|
||||
// on page 2. The page_size query value disambiguates the two list stubs.
|
||||
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
|
||||
page2IDs := []string{"msg_d", "msg_e"}
|
||||
|
||||
// Folders list: registered exactly once, non-reusable. Any second folder
|
||||
// lookup (the bug) fails the test with "no stub for GET .../folders".
|
||||
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
|
||||
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
|
||||
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
|
||||
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
|
||||
// Messages list, page 2: 2 ids, terminal.
|
||||
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
|
||||
// Batch metadata fetch for all 5 ids.
|
||||
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_a", "Subject A"),
|
||||
mailTriageBatchMessage("msg_b", "Subject B"),
|
||||
mailTriageBatchMessage("msg_c", "Subject C"),
|
||||
mailTriageBatchMessage("msg_d", "Subject D"),
|
||||
mailTriageBatchMessage("msg_e", "Subject E"),
|
||||
})
|
||||
|
||||
args := []string{
|
||||
"+triage",
|
||||
"--as", "user",
|
||||
"--mailbox", mailbox,
|
||||
"--filter", `{"folder":"` + folderName + `"}`,
|
||||
"--max", "5",
|
||||
"--format", "json",
|
||||
}
|
||||
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != 5 {
|
||||
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
|
||||
}
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
|
||||
}
|
||||
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
|
||||
// non-reusable; reg.Verify (deferred above) asserts each was matched
|
||||
// exactly once. Combined with the non-reusable folders stub, this is the
|
||||
// proof that the folders list API was called exactly once across both
|
||||
// pages — the core invariant the fix restores.
|
||||
}
|
||||
|
||||
@@ -308,9 +308,6 @@ var MinutesSearch = common.Shortcut{
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
|
||||
@@ -609,8 +609,6 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -619,7 +617,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -644,9 +641,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Notice string `json:"notice"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
@@ -657,9 +651,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Notice != notice {
|
||||
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
|
||||
|
||||
@@ -444,7 +444,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
t, tc.shortcut,
|
||||
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
|
||||
)
|
||||
requireValidation(t, standaloneErr, tc.wantContains)
|
||||
if standaloneErr == nil {
|
||||
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
|
||||
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
|
||||
}
|
||||
|
||||
// Batch path: translate the matching sub-op. The translator wraps
|
||||
// the inner error with "operations[i] (<shortcut>): " — assert the
|
||||
@@ -458,12 +463,17 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, batchErr := translateBatchOp(rawOp, testToken, 0)
|
||||
batchVE := requireValidation(t, batchErr, tc.wantContains)
|
||||
if batchErr == nil {
|
||||
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(batchErr.Error(), tc.wantContains) {
|
||||
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
|
||||
}
|
||||
// And the wrap context must include the sub-op index + shortcut
|
||||
// name so error reports stay actionable in multi-op batches.
|
||||
wrapHint := "operations[0] (" + tc.subShortcut + "):"
|
||||
if !strings.Contains(batchVE.Message, wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
|
||||
if !strings.Contains(batchErr.Error(), wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -519,7 +529,12 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -577,7 +592,12 @@ func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -708,7 +728,12 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -769,7 +794,12 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -35,9 +37,18 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
ve := requireValidation(t, err, "existing file")
|
||||
if !strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected guard error when --csv names an existing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
|
||||
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("problem = %+v, want validation/invalid_argument", p)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("guard error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--csv" {
|
||||
t.Errorf("param = %q, want --csv", ve.Param)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -43,7 +44,12 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
"range": "A1:H17",
|
||||
})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
requireValidation(t, err, "--start-cell and --range are mutually exclusive")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
|
||||
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
|
||||
@@ -55,7 +61,12 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
requireValidation(t, err, "--start-cell or --range is required")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
|
||||
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
|
||||
|
||||
@@ -526,7 +526,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -543,6 +543,13 @@
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -593,7 +600,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."
|
||||
"desc": "Local save path; export is triggered but not downloaded when omitted"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -1380,6 +1387,13 @@
|
||||
"required": "optional",
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
},
|
||||
{
|
||||
"name": "dataframe-out",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
@@ -2062,19 +2076,26 @@
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON: top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"required": "xor",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dataframe",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
|
||||
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
|
||||
@@ -44,8 +44,6 @@
|
||||
"+sheet-hide",
|
||||
"+sheet-unhide",
|
||||
"+sheet-set-tab-color",
|
||||
"+sheet-show-gridline",
|
||||
"+sheet-hide-gridline",
|
||||
"+chart-create",
|
||||
"+chart-update",
|
||||
"+chart-delete",
|
||||
@@ -6263,7 +6261,7 @@
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -6632,7 +6630,7 @@
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。整体形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`。一行式用法:`{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
||||
@@ -48,16 +47,19 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
_, _, err := func() (string, string, error) {
|
||||
stdout, stderr, err := func() (string, string, error) {
|
||||
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
|
||||
reg.Register(stub)
|
||||
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
|
||||
err := parent.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}()
|
||||
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeServerError, "")
|
||||
if !strings.Contains(p.Message, "1310201") && !strings.Contains(p.Message, "not found") {
|
||||
t.Errorf("expected error code or message in problem; got message=%q", p.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
|
||||
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +113,18 @@ func TestExecute_WikiURLWrongObjType(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
requireValidation(t, err, "obj_type")
|
||||
if err == nil {
|
||||
t.Fatalf("want error for non-sheet wiki node; out=%s", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "obj_type") {
|
||||
t.Fatalf("error = %v, want mention of obj_type", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("wrong-obj_type error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLIncompleteNode treats an incomplete get_node response
|
||||
@@ -143,40 +154,6 @@ func TestExecute_WikiURLIncompleteNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeMove_WikiURL guards the transformExecuteFn path: +range-move
|
||||
// and +range-copy use a named Execute helper (not an inline func), so they must
|
||||
// still resolve a /wiki/ URL to the backing spreadsheet token before calling
|
||||
// transform_range. The tool stub is keyed on the resolved obj_token, so an
|
||||
// unresolved node_token would miss it and fail this test.
|
||||
func TestExecute_RangeMove_WikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": testToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tool := toolOutputStub(testToken, "write", `{"updated_range":"A10:B11"}`)
|
||||
out, err := runShortcutWithStubs(t, RangeMove,
|
||||
[]string{
|
||||
"--url", "https://example.feishu.cn/wiki/wikTestNODE",
|
||||
"--sheet-id", testSheetID,
|
||||
"--source-range", "A1:B2",
|
||||
"--target-range", "A10",
|
||||
}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
|
||||
// when only --sheet-name is given (and --source-index omitted) first
|
||||
// reads the workbook structure to derive sheet_id + source_index, then
|
||||
@@ -547,14 +524,10 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-state
|
||||
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
|
||||
// contract: when the spreadsheet is created but the follow-up fill can't resolve
|
||||
// its first sheet, the result lands on stdout as an ok:false envelope carrying
|
||||
// spreadsheet_token + reason + a structured cause field, and the process exits
|
||||
// with the bare partial-failure signal — matching +table-put's tablePutPartial
|
||||
// shape so agents see one consistent "side effect landed but follow-up didn't"
|
||||
// contract across the sheets domain (instead of the old failed_precondition
|
||||
// stderr envelope).
|
||||
// its first sheet, the error must be structured and retain spreadsheet_token so
|
||||
// the caller can recover instead of orphaning the new workbook.
|
||||
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
@@ -568,41 +541,33 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
},
|
||||
}
|
||||
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
|
||||
// spreadsheet already exists — exercising the partial-state path.
|
||||
// spreadsheet already exists — exercising the partial-success path.
|
||||
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial-failure exit signal; got nil. out=%s", out)
|
||||
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError exit signal; got %T %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", jerr, out)
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype)
|
||||
}
|
||||
if ok, _ := env["ok"].(bool); ok {
|
||||
t.Errorf("partial-state envelope must be ok:false; got out=%s", out)
|
||||
if !strings.Contains(p.Message, "shtNEW") {
|
||||
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got := data["spreadsheet_token"]; got != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW (recovery requires the token to be in the envelope)", got)
|
||||
if !strings.Contains(p.Hint, "spreadsheet_token") {
|
||||
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
|
||||
}
|
||||
reason, _ := data["reason"].(string)
|
||||
if !strings.Contains(reason, "shtNEW") {
|
||||
t.Errorf("reason = %q, want the spreadsheet token named for recovery", reason)
|
||||
// The underlying fill failure is preserved as the cause so its subtype and
|
||||
// log_id stay diagnosable rather than being flattened into the message.
|
||||
inner := errors.Unwrap(err)
|
||||
if inner == nil {
|
||||
t.Fatalf("expected the underlying fill failure preserved as the cause")
|
||||
}
|
||||
hint, _ := data["hint"].(string)
|
||||
if !strings.Contains(hint, "spreadsheet_token") {
|
||||
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", hint)
|
||||
}
|
||||
// The underlying fill failure's typed shape is flattened into the cause
|
||||
// field so the inner subtype stays diagnosable from the JSON envelope alone.
|
||||
cause, _ := data["cause"].(map[string]interface{})
|
||||
if got := cause["subtype"]; got != string(errs.SubtypeInvalidResponse) {
|
||||
t.Errorf("cause.subtype = %v, want the underlying invalid_response subtype", got)
|
||||
if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -923,6 +923,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dataframe-out", Kind: "own", Type: "string", Required: "optional", Desc: "Write the typed table as one Arrow IPC file (Feather v2) instead of the default JSON. Pass `@<path>` for a file or `-` for binary stdout (same convention as other binary I/O flags). Mirror of the input-side `--dataframe` on `+table-put` / `+workbook-create` — pandas users round-trip via `df = pd.read_feather(\"x.arrow\")` or `pd.read_feather(io.BytesIO(stdout))`. Single-sheet only: requires `--sheet-id` or `--sheet-name`; whole-workbook reads keep the default JSON path. Column types come from the typed read-back (string/number/date/bool); per-column `number_format` is preserved as Arrow field metadata so the Arrow file can round-trip straight back through `+table-put --dataframe`."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -931,8 +932,9 @@ var flagDefs = map[string]commandDef{
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON: top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "xor", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON, XOR with `--dataframe`: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it with `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "xor", Desc: "Single-sheet typed table from one Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()` writes), XOR with `--sheets`. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema (int*/uint*/float* → number, date32/date64/timestamp → date, utf8/large_utf8 → string, bool → bool); per-column `number_format` may be set via Arrow field metadata (`pa.field(\"price\", pa.float64(), metadata={b\"number_format\": b\"$#,##0.00\"})`). Writes the sheet at default placement: name `Sheet1` (created when absent), overwrite from A1 with header. For a different sheet name, anchor, mode, or to write multiple sheets, use `--sheets` instead."},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -942,8 +944,9 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`. Agents typically build it from a DataFrame via `{**json.loads(df.to_json(orient=\"split\")), \"dtypes\": df.dtypes.astype(str).to_dict()}`. Mutually exclusive with --values and --dataframe. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dataframe", Kind: "own", Type: "string", Required: "optional", Desc: "Single-sheet typed table from one Arrow IPC file (Feather v2 — what `pandas.DataFrame.to_feather()` writes), mutually exclusive with --values and --sheets. Pass `@<path>` for a file or `-` for binary stdin (same convention as other input flags). Arrow bytes are read raw — no TrimSpace / BOM strip — so the IPC magic survives intact (unlike text input flags). Column types come from the Arrow schema; per-column `number_format` may be set via Arrow field metadata. Creates the workbook and fills its default sheet (`Sheet1` — adopted in place, no empty Sheet1 left behind). For multi-sheet or non-default placement, use `--sheets` instead."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -954,7 +957,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ─── --print-schema runtime introspection ─────────────────────────────
|
||||
@@ -93,7 +91,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
|
||||
}
|
||||
if flagName == "" {
|
||||
flags := make([]string, 0, len(entry))
|
||||
@@ -114,9 +112,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
WithParam("--flag-name")
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
|
||||
@@ -84,12 +84,12 @@ func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
|
||||
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
|
||||
ve := requireValidation(t, err, "+chart-create")
|
||||
if !strings.Contains(ve.Message, "properties") {
|
||||
t.Errorf("message should list available flags; got %q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown flag, got nil")
|
||||
}
|
||||
if ve.Param != "--flag-name" {
|
||||
t.Errorf("param = %q, want --flag-name", ve.Param)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
|
||||
t.Errorf("error should mention shortcut + available flags; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
// suggested command is guaranteed to print it.
|
||||
return sheetsValidationForFlag(name,
|
||||
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
||||
name, vErr.Error(), command, name).WithCause(vErr)
|
||||
name, vErr.Error(), command, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -478,9 +478,11 @@ func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "summarize_by")
|
||||
if !strings.Contains(ve.Message, "not in enum") {
|
||||
t.Errorf("error = %q, want enum hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected enum violation, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
|
||||
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,9 +499,11 @@ func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "values")
|
||||
if !strings.Contains(ve.Message, "minimum is 1") {
|
||||
t.Errorf("error = %q, want minimum-is-1 hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected minItems violation for empty values, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
|
||||
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,9 +520,11 @@ func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "row")
|
||||
if !strings.Contains(ve.Message, "below minimum") {
|
||||
t.Errorf("error = %q, want below-minimum hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected minimum violation for row:-1, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
|
||||
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,9 +554,11 @@ func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := validateInputAgainstSchema(fv, bad)
|
||||
ve := requireValidation(t, err, "collapse")
|
||||
if !strings.Contains(ve.Message, `expected type "string"`) {
|
||||
t.Errorf("error = %q, want string-type hint", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatal("expected additionalProperties violation, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
|
||||
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,14 +597,17 @@ func TestValidateValueAgainstSchema_PrintSchemaHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
fv := mapFlagView{command: "+cells-set"}
|
||||
err := validateValueAgainstSchema(fv, "cells", "not-an-array")
|
||||
if err == nil {
|
||||
t.Fatal("expected schema validation error for wrong --cells shape")
|
||||
}
|
||||
msg := err.Error()
|
||||
// Underlying shape error is preserved (substring callers still match).
|
||||
ve := requireValidation(t, err, `expected type "array"`)
|
||||
if !strings.Contains(msg, `expected type "array"`) {
|
||||
t.Errorf("want underlying shape error preserved; got %q", msg)
|
||||
}
|
||||
// And the actionable --print-schema hint is appended with the exact
|
||||
// command + flag, so a copy-paste fetches the schema for this pair.
|
||||
if !strings.Contains(ve.Message, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
|
||||
t.Errorf("want --print-schema hint with command+flag; got %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--cells" {
|
||||
t.Errorf("param = %q, want --cells", ve.Param)
|
||||
if !strings.Contains(msg, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
|
||||
t.Errorf("want --print-schema hint with command+flag; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,53 +81,6 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
//
|
||||
// Replaces the older "strings.Contains(stdout+stderr+err.Error(), ...)" pattern
|
||||
// across sheets tests: substring on a rendered envelope was brittle (any
|
||||
// message tweak silently broke it) and didn't verify that the typed contract —
|
||||
// category / subtype / cause preservation — held. Per coding guideline
|
||||
// "Error-path tests must assert typed metadata via errs.ProblemOf
|
||||
// (category / subtype / param) and cause preservation, not message substrings
|
||||
// alone."
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for the most common case: a typed
|
||||
// CategoryValidation error with SubtypeInvalidArgument. Returns the
|
||||
// *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
|
||||
func TestSheetHelpersValidationMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -346,6 +299,7 @@ func TestParseSpreadsheetRef(t *testing.T) {
|
||||
{name: "neither provided", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := parseSpreadsheetRef(mk(tc.url, tc.tok))
|
||||
|
||||
@@ -89,9 +89,8 @@ var BatchUpdate = common.Shortcut{
|
||||
}
|
||||
|
||||
// batchUpdateInput translates the user-supplied CLI-shape operations array
|
||||
// into the MCP batch_update payload. Returns ValidationErrorf-typed errors
|
||||
// (errs.ValidationError) on any per-op shape problem (translator validates
|
||||
// each entry).
|
||||
// into the MCP batch_update payload. Returns FlagErrorf-typed errors on
|
||||
// any per-op shape problem (translator validates each entry).
|
||||
func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
|
||||
rawOps, err := parseBatchOperationsFlag(runtime)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -165,16 +166,18 @@ func TestCellsBatchClear_Guards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// sheetless range → prefix guard (shared with the dropdown fan-outs).
|
||||
_, _, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["A1:A10"]`,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "must include a sheet prefix")
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
|
||||
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// missing --yes → confirmation_required (high-risk-write).
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A10"]`,
|
||||
})
|
||||
@@ -265,32 +268,38 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// dropdown-update with sheetless range
|
||||
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["A2:A5"]`,
|
||||
"--options", `["a"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "must include a sheet prefix")
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
|
||||
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// batch-update with empty operations
|
||||
_, _, err = runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", `[]`,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "non-empty JSON array")
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
|
||||
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// dropdown-update with non-array --options (object instead) → array guard
|
||||
// (now via schema validator at parseJSONFlag time)
|
||||
_, _, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", `["sheet1!A1:A2"]`,
|
||||
"--options", `{"not":"array"}`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, `expected type "array"`)
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
|
||||
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
|
||||
@@ -313,13 +322,15 @@ func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--ranges", tc.ranges,
|
||||
"--options", `["a"]`,
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, tc.want)
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
|
||||
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -408,13 +419,18 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
|
||||
"--url", testURL,
|
||||
"--operations", tc.opsJSON,
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, tc.wantMatch)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
|
||||
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
554
shortcuts/sheets/lark_sheet_dataframe.go
Normal file
554
shortcuts/sheets/lark_sheet_dataframe.go
Normal file
@@ -0,0 +1,554 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ─── --dataframe (Arrow IPC / Feather v2 binary input) ────────────────
|
||||
//
|
||||
// --dataframe is the binary-typed twin of --sheets. The wire payload is one
|
||||
// Arrow IPC file (a.k.a. Feather v2 — what `pandas.DataFrame.to_feather()`
|
||||
// writes), single schema, optionally multi-batch. Type / format are read off
|
||||
// the Arrow schema (no separate dtypes/formats maps), and per-column number
|
||||
// format can be set via the field's `number_format` metadata key:
|
||||
//
|
||||
// pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})
|
||||
//
|
||||
// One DataFrame writes into one sub-sheet at fixed defaults: name `Sheet1`
|
||||
// (adopted in place by +workbook-create; created when absent by +table-put),
|
||||
// overwrite from A1 with header on, allow_overwrite=true. The shortcut
|
||||
// surface is deliberately the one flag — anything that needs a different
|
||||
// sheet name / anchor / mode / multi-sheet falls back to --sheets, whose
|
||||
// JSON payload already carries every knob.
|
||||
//
|
||||
// Binary IO note: --dataframe bypasses the text-oriented Input resolver
|
||||
// (`runtime.Str("dataframe")` carries a *path*, not file contents). Reading
|
||||
// the Arrow bytes through that resolver would TrimSpace the trailing IPC
|
||||
// magic / corrupt non-UTF8 bytes. Path → FileIO.Open → io.ReadAll keeps the
|
||||
// stream byte-exact. "-" reads from stdin directly.
|
||||
|
||||
// dataframeDefaultSheetName is the sub-sheet name --dataframe writes into.
|
||||
// Matches valuesSheetName so +workbook-create adopts the brand-new
|
||||
// workbook's default sheet in place (no stray empty Sheet1 left behind);
|
||||
// +table-put creates Sheet1 if it doesn't already exist.
|
||||
const dataframeDefaultSheetName = valuesSheetName
|
||||
|
||||
// parseDataframePayload reads the --dataframe path (Arrow IPC file) and
|
||||
// composes a single-sheet tablePayload at the fixed default placement.
|
||||
// Network-free: safe from Validate and DryRun. The resulting tableSheetSpec
|
||||
// rides the same buildSheetMatrix / buildTypedCell path as a --sheets entry,
|
||||
// so downstream is unaware of where the rows came from.
|
||||
func parseDataframePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
|
||||
raw := strings.TrimSpace(rctx.Str("dataframe"))
|
||||
if raw == "" {
|
||||
return nil, common.ValidationErrorf("--dataframe is required")
|
||||
}
|
||||
data, err := readDataframeBytes(rctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec, err := decodeArrowToSheet(data, dataframeDefaultSheetName)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: %v", err)
|
||||
}
|
||||
payload := &tablePayload{Sheets: []tableSheetSpec{spec}}
|
||||
if err := payload.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// dataframeStdinCache holds the bytes read from stdin on the first call so a
|
||||
// later call (Validate → Execute / DryRun) gets the same bytes instead of an
|
||||
// empty stream — stdin is single-shot, but parseDataframePayload runs
|
||||
// multiple times per command invocation. Process-wide is fine: lark-cli is
|
||||
// one-shot (one command per process). Tests reset by setting it back to nil.
|
||||
var dataframeStdinCache []byte
|
||||
|
||||
// readDataframeBytes resolves --dataframe to raw binary. A literal `@` prefix
|
||||
// is tolerated for symmetry with --sheets (`@/tmp/x.arrow` and `/tmp/x.arrow`
|
||||
// both work). `-` reads stdin verbatim — cached on first call so Validate /
|
||||
// Execute / DryRun all see the same bytes. Bytes are returned untouched: no
|
||||
// TrimSpace, no BOM strip — both would corrupt an Arrow IPC stream.
|
||||
func readDataframeBytes(rctx *common.RuntimeContext, raw string) ([]byte, error) {
|
||||
if raw == "-" {
|
||||
if dataframeStdinCache != nil {
|
||||
return dataframeStdinCache, nil
|
||||
}
|
||||
io := rctx.IO()
|
||||
if io == nil || io.In == nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: stdin is not available")
|
||||
}
|
||||
data, err := readAllBytes(io.In)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: read stdin: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.ValidationErrorf("--dataframe: stdin is empty")
|
||||
}
|
||||
dataframeStdinCache = data
|
||||
return data, nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("--dataframe: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, common.ValidationErrorf("--dataframe: file %q is empty", path)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// readAllBytes is a thin wrapper so tests can fake the io.Reader without
|
||||
// importing io. Mirrors io.ReadAll exactly.
|
||||
func readAllBytes(r io.Reader) ([]byte, error) { return io.ReadAll(r) }
|
||||
|
||||
// decodeArrowToSheet reads `data` as an Arrow IPC file (single schema,
|
||||
// possibly multi-batch) and produces a tableSheetSpec with name + columns +
|
||||
// rows filled in. Sheet placement (start_cell / mode / header / overwrite) is
|
||||
// not touched here — parseDataframePayload layers those on from CLI flags.
|
||||
func decodeArrowToSheet(data []byte, sheetName string) (tableSheetSpec, error) {
|
||||
reader, err := ipc.NewFileReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("invalid Arrow IPC file (expected pandas df.to_feather output): %v", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
schema := reader.Schema()
|
||||
if schema == nil || schema.NumFields() == 0 {
|
||||
return tableSheetSpec{}, fmt.Errorf("Arrow schema has no fields") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
ncols := schema.NumFields()
|
||||
cols := make([]tableColumnSpec, ncols)
|
||||
seen := make(map[string]bool, ncols)
|
||||
for i := 0; i < ncols; i++ {
|
||||
f := schema.Field(i)
|
||||
name := f.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
if seen[name] {
|
||||
return tableSheetSpec{}, fmt.Errorf("duplicate column name %q", name) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
seen[name] = true
|
||||
typ, format, err := arrowFieldToTypeFormat(f)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("column %q: %v", name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
cols[i] = tableColumnSpec{Name: name, Type: typ, Format: format}
|
||||
}
|
||||
|
||||
var rows [][]interface{}
|
||||
for b := 0; b < reader.NumRecords(); b++ {
|
||||
rec, err := reader.RecordAt(b)
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, fmt.Errorf("read record batch %d: %v", b, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
batchRows, err := arrowRecordToRows(rec, cols)
|
||||
rec.Release()
|
||||
if err != nil {
|
||||
return tableSheetSpec{}, err
|
||||
}
|
||||
rows = append(rows, batchRows...)
|
||||
}
|
||||
|
||||
return tableSheetSpec{Name: sheetName, Columns: cols, Rows: rows}, nil
|
||||
}
|
||||
|
||||
// arrowFieldToTypeFormat maps an Arrow field to the internal (type, format)
|
||||
// pair. The field's `number_format` metadata key — when present — sets the
|
||||
// Excel number_format string verbatim; otherwise sensible defaults are
|
||||
// applied per type (`@` text for strings, `yyyy-mm-dd` for dates).
|
||||
func arrowFieldToTypeFormat(f arrow.Field) (typ, format string, err error) {
|
||||
if v, ok := f.Metadata.GetValue("number_format"); ok {
|
||||
format = strings.TrimSpace(v)
|
||||
}
|
||||
switch f.Type.(type) {
|
||||
case *arrow.StringType, *arrow.LargeStringType:
|
||||
if format == "" {
|
||||
format = "@"
|
||||
}
|
||||
return "string", format, nil
|
||||
case *arrow.BooleanType:
|
||||
return "bool", format, nil
|
||||
case *arrow.Date32Type, *arrow.Date64Type, *arrow.TimestampType:
|
||||
if format == "" {
|
||||
format = "yyyy-mm-dd"
|
||||
}
|
||||
return "date", format, nil
|
||||
}
|
||||
if isArrowNumericType(f.Type) {
|
||||
return "number", format, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("unsupported Arrow type %s (want string/number/date/bool)", f.Type.Name()) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
func isArrowNumericType(t arrow.DataType) bool {
|
||||
switch t.ID() {
|
||||
case arrow.INT8, arrow.INT16, arrow.INT32, arrow.INT64,
|
||||
arrow.UINT8, arrow.UINT16, arrow.UINT32, arrow.UINT64,
|
||||
arrow.FLOAT16, arrow.FLOAT32, arrow.FLOAT64:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// arrowRecordToRows transposes one column-batch into row-major
|
||||
// [][]interface{} matched to `cols`. Cells are stamped with the same value
|
||||
// shapes buildTypedCell expects from the JSON path: nil for nulls,
|
||||
// json.Number for numerics (precision-preserving), `yyyy-mm-dd` strings for
|
||||
// dates/timestamps, bool for booleans, string for strings.
|
||||
func arrowRecordToRows(rec arrow.Record, cols []tableColumnSpec) ([][]interface{}, error) {
|
||||
if int(rec.NumCols()) != len(cols) {
|
||||
return nil, fmt.Errorf("record has %d cols, schema declared %d", rec.NumCols(), len(cols)) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
nrows := int(rec.NumRows())
|
||||
rows := make([][]interface{}, nrows)
|
||||
for r := range rows {
|
||||
rows[r] = make([]interface{}, len(cols))
|
||||
}
|
||||
for c := range cols {
|
||||
arr := rec.Column(c)
|
||||
for r := 0; r < nrows; r++ {
|
||||
if arr.IsNull(r) {
|
||||
continue
|
||||
}
|
||||
v, err := arrowCellValue(arr, r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %v", r, cols[c].Name, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
rows[r][c] = v
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func arrowCellValue(arr arrow.Array, i int) (interface{}, error) {
|
||||
switch a := arr.(type) {
|
||||
case *array.String:
|
||||
return a.Value(i), nil
|
||||
case *array.LargeString:
|
||||
return a.Value(i), nil
|
||||
case *array.Boolean:
|
||||
return a.Value(i), nil
|
||||
case *array.Int8:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int16:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int32:
|
||||
return json.Number(strconv.FormatInt(int64(a.Value(i)), 10)), nil
|
||||
case *array.Int64:
|
||||
return json.Number(strconv.FormatInt(a.Value(i), 10)), nil
|
||||
case *array.Uint8:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint16:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint32:
|
||||
return json.Number(strconv.FormatUint(uint64(a.Value(i)), 10)), nil
|
||||
case *array.Uint64:
|
||||
return json.Number(strconv.FormatUint(a.Value(i), 10)), nil
|
||||
case *array.Float16:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i).Float32()), 'f', -1, 32)), nil
|
||||
case *array.Float32:
|
||||
return json.Number(strconv.FormatFloat(float64(a.Value(i)), 'f', -1, 32)), nil
|
||||
case *array.Float64:
|
||||
return json.Number(strconv.FormatFloat(a.Value(i), 'f', -1, 64)), nil
|
||||
case *array.Date32:
|
||||
// Date32: days since 1970-01-01 (epoch). Multiply to seconds, format
|
||||
// in UTC so timezone offset can't flip the calendar date.
|
||||
t := time.Unix(int64(a.Value(i))*86400, 0).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Date64:
|
||||
t := time.UnixMilli(int64(a.Value(i))).UTC()
|
||||
return t.Format("2006-01-02"), nil
|
||||
case *array.Timestamp:
|
||||
ts := int64(a.Value(i))
|
||||
unit := a.DataType().(*arrow.TimestampType).Unit
|
||||
var t time.Time
|
||||
switch unit {
|
||||
case arrow.Second:
|
||||
t = time.Unix(ts, 0).UTC()
|
||||
case arrow.Millisecond:
|
||||
t = time.UnixMilli(ts).UTC()
|
||||
case arrow.Microsecond:
|
||||
t = time.UnixMicro(ts).UTC()
|
||||
case arrow.Nanosecond:
|
||||
t = time.Unix(0, ts).UTC()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported timestamp unit %v", unit) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return t.Format("2006-01-02"), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported Arrow array %T", arr) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
// ─── --dataframe-out (Arrow IPC binary output, mirror of --dataframe) ──
|
||||
//
|
||||
// +table-get's binary read-back: encode one sheet's typed read-back as an
|
||||
// Arrow IPC file (Feather v2), so pandas can `pd.read_feather(path)` /
|
||||
// `pd.read_feather(BytesIO(stdout))` symmetrically with the put side.
|
||||
// Single-sheet only — Arrow IPC carries one schema per file. The JSON path
|
||||
// is unchanged; --dataframe-out swaps the encoder for callers that already
|
||||
// have pandas / pyarrow in their pipeline.
|
||||
|
||||
// encodeSheetMapToArrowIPC turns one readSheetAsSpec output into an Arrow IPC
|
||||
// file blob. Internal column types are recovered from `dtypes` (the wire
|
||||
// proxy for the typed protocol), and per-column `number_format` rides through
|
||||
// as Arrow field metadata so the file feeds straight back into
|
||||
// `+table-put --dataframe`.
|
||||
func encodeSheetMapToArrowIPC(sheet map[string]interface{}) ([]byte, error) {
|
||||
columns, _ := sheet["columns"].([]interface{})
|
||||
if len(columns) == 0 {
|
||||
return nil, fmt.Errorf("sheet has no columns") //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
dtypes, _ := sheet["dtypes"].(map[string]interface{})
|
||||
formats, _ := sheet["formats"].(map[string]interface{})
|
||||
// `data` arrives as either []interface{} (when the sheet came through a
|
||||
// JSON round-trip / unit-test fixture) or [][]interface{} (the shape
|
||||
// readSheetAsSpec directly emits in production). Accept both — anything
|
||||
// else falls through to a zero-row table.
|
||||
var rawData [][]interface{}
|
||||
switch d := sheet["data"].(type) {
|
||||
case [][]interface{}:
|
||||
rawData = d
|
||||
case []interface{}:
|
||||
rawData = make([][]interface{}, len(d))
|
||||
for i, r := range d {
|
||||
rawData[i], _ = r.([]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
ncols := len(columns)
|
||||
colNames := make([]string, ncols)
|
||||
colTypes := make([]string, ncols)
|
||||
fields := make([]arrow.Field, ncols)
|
||||
for i, c := range columns {
|
||||
name, _ := c.(string)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("column %d has empty name", i) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
colNames[i] = name
|
||||
dt, _ := dtypes[name].(string)
|
||||
colTypes[i] = dtypeToInternalType(dt)
|
||||
var meta arrow.Metadata
|
||||
if formats != nil {
|
||||
if nf, ok := formats[name].(string); ok && strings.TrimSpace(nf) != "" {
|
||||
meta = arrow.NewMetadata([]string{"number_format"}, []string{nf})
|
||||
}
|
||||
}
|
||||
fields[i] = arrow.Field{
|
||||
Name: name,
|
||||
Type: internalTypeToArrowType(colTypes[i]),
|
||||
Nullable: true,
|
||||
Metadata: meta,
|
||||
}
|
||||
}
|
||||
schema := arrow.NewSchema(fields, nil)
|
||||
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
for r, row := range rawData {
|
||||
if len(row) != ncols {
|
||||
return nil, fmt.Errorf("row %d has %d cells, want %d", r, len(row), ncols) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
for c := 0; c < ncols; c++ {
|
||||
if err := appendArrowCell(rb.Field(c), colTypes[c], row[c]); err != nil {
|
||||
return nil, fmt.Errorf("row %d column %q: %v", r, colNames[c], err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
}
|
||||
}
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
var buf bytesWriterSeeker
|
||||
w, err := ipc.NewFileWriter(&buf, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc.NewFileWriter: %v", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
return nil, fmt.Errorf("write record: %v", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close writer: %v", err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return buf.buf, nil
|
||||
}
|
||||
|
||||
// dtypeToInternalType inverts typeToDtype so the Arrow encoder can pick an
|
||||
// internal column type from the wire-level dtype string. Unknown / object
|
||||
// falls back to string (lossless: every cell is already typed as such).
|
||||
func dtypeToInternalType(dtype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(dtype)) {
|
||||
case "float64", "float32", "int64", "int32", "int16", "int8",
|
||||
"uint64", "uint32", "uint16", "uint8":
|
||||
return "number"
|
||||
case "bool", "boolean":
|
||||
return "bool"
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(dtype), "datetime") {
|
||||
return "date"
|
||||
}
|
||||
return "string"
|
||||
}
|
||||
|
||||
// internalTypeToArrowType is the put-side dtypeToTypeFormat dual: maps the
|
||||
// internal column type to the Arrow data type the encoder builds a column
|
||||
// with. Numbers go to float64 because +table-get can't tell int from float
|
||||
// from a number_format alone — float64 covers both losslessly for the cell
|
||||
// ranges Lark Sheets accepts.
|
||||
func internalTypeToArrowType(typ string) arrow.DataType {
|
||||
switch typ {
|
||||
case "number":
|
||||
return arrow.PrimitiveTypes.Float64
|
||||
case "date":
|
||||
return arrow.FixedWidthTypes.Date32
|
||||
case "bool":
|
||||
return arrow.FixedWidthTypes.Boolean
|
||||
}
|
||||
return arrow.BinaryTypes.String
|
||||
}
|
||||
|
||||
// appendArrowCell stamps one cell into its column builder. Cell shape matches
|
||||
// what cellToTyped emits on the JSON path: json.Number for numbers, ISO
|
||||
// `yyyy-mm-dd` string for dates, plain string for strings, bool for bools,
|
||||
// nil for empty. Anything off-shape errors so the caller doesn't silently
|
||||
// emit nulls for malformed data.
|
||||
func appendArrowCell(b array.Builder, typ string, v interface{}) error {
|
||||
if v == nil {
|
||||
b.AppendNull()
|
||||
return nil
|
||||
}
|
||||
switch typ {
|
||||
case "string":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("string expects string value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
b.(*array.StringBuilder).Append(s)
|
||||
case "number":
|
||||
f, err := arrowNumber(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.(*array.Float64Builder).Append(f)
|
||||
case "date":
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("date expects ISO yyyy-mm-dd string, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return fmt.Errorf("date parse %q: %v", s, err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
b.(*array.Date32Builder).Append(arrow.Date32FromTime(t))
|
||||
case "bool":
|
||||
bb, ok := v.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("bool expects bool, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
b.(*array.BooleanBuilder).Append(bb)
|
||||
default:
|
||||
return fmt.Errorf("unsupported internal type %q", typ) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// arrowNumber converts the number cell shape readSheetAsSpec emits
|
||||
// (json.Number) plus the float fallback to float64 for the Arrow builder.
|
||||
func arrowNumber(v interface{}) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case json.Number:
|
||||
f, err := n.Float64()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("number parse %q: %v", n.String(), err) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return f, nil
|
||||
case float64:
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("number expects numeric value, got %T", v) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
|
||||
// bytesWriterSeeker is a 10-line in-memory io.WriteSeeker for
|
||||
// ipc.NewFileWriter, which seeks back to patch a footer offset. Using a
|
||||
// buffer (instead of a temp file or os.Stdout, which isn't seekable) keeps
|
||||
// --dataframe-out's stdout path zero-IO and stays straightforward.
|
||||
type bytesWriterSeeker struct {
|
||||
buf []byte
|
||||
pos int64
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Write(p []byte) (int, error) {
|
||||
end := w.pos + int64(len(p))
|
||||
if end > int64(len(w.buf)) {
|
||||
w.buf = append(w.buf, make([]byte, end-int64(len(w.buf)))...)
|
||||
}
|
||||
n := copy(w.buf[w.pos:], p)
|
||||
w.pos = end
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (w *bytesWriterSeeker) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
w.pos = offset
|
||||
case io.SeekCurrent:
|
||||
w.pos += offset
|
||||
case io.SeekEnd:
|
||||
w.pos = int64(len(w.buf)) + offset
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown whence %d", whence) //nolint:forbidigo // intermediate error; the command layer wraps it into a typed --dataframe/--dataframe-out validation error
|
||||
}
|
||||
return w.pos, nil
|
||||
}
|
||||
|
||||
// writeDataframeOut dispatches the encoded Arrow bytes to wherever --dataframe-out
|
||||
// points: `-` → process stdout, `@<path>` or plain path → local file. Symmetric
|
||||
// with readDataframeBytes on the input side: same `@` tolerance, same TrimPrefix
|
||||
// semantics, and an absolute path will still get rejected by FileIO's SafePath.
|
||||
func writeDataframeOut(rctx *common.RuntimeContext, raw string, data []byte) error {
|
||||
if raw == "-" {
|
||||
out := rctx.IO()
|
||||
if out == nil || out.Out == nil {
|
||||
return common.ValidationErrorf("--dataframe-out: stdout is not available")
|
||||
}
|
||||
if _, err := out.Out.Write(data); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write stdout").WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
path := strings.TrimPrefix(raw, "@")
|
||||
fio := rctx.FileIO()
|
||||
if fio == nil {
|
||||
return common.ValidationErrorf("--dataframe-out: file output is not available in this context")
|
||||
}
|
||||
// FileIO.Save validates the path via SafeOutputPath (the same sandbox
|
||||
// readDataframeBytes hits on the input side) and writes atomically, so we
|
||||
// don't need an extra ValidatePath call here.
|
||||
if _, err := fio.Save(path, fileio.SaveOptions{ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "--dataframe-out: write %q", path).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
378
shortcuts/sheets/lark_sheet_dataframe_test.go
Normal file
378
shortcuts/sheets/lark_sheet_dataframe_test.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/apache/arrow/go/v17/arrow"
|
||||
"github.com/apache/arrow/go/v17/arrow/array"
|
||||
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||
)
|
||||
|
||||
// buildArrowIPC writes one record into a Feather v2 (Arrow IPC file) blob.
|
||||
// Used by the round-trip tests below to stand in for what
|
||||
// `pandas.DataFrame.to_feather(path)` would produce; saves the tests from
|
||||
// depending on a pandas-shaped fixture file.
|
||||
//
|
||||
// ipc.NewFileWriter wants an io.WriteSeeker (it back-patches a footer
|
||||
// offset), so we write to a temp file and read the bytes back — simpler than
|
||||
// re-implementing a seekable in-memory buffer.
|
||||
func buildArrowIPC(t *testing.T, schema *arrow.Schema, build func(b *array.RecordBuilder)) []byte {
|
||||
t.Helper()
|
||||
mem := memory.NewGoAllocator()
|
||||
rb := array.NewRecordBuilder(mem, schema)
|
||||
defer rb.Release()
|
||||
build(rb)
|
||||
rec := rb.NewRecord()
|
||||
defer rec.Release()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "df.arrow")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp arrow file: %v", err)
|
||||
}
|
||||
w, err := ipc.NewFileWriter(f, ipc.WithSchema(schema), ipc.WithAllocator(mem))
|
||||
if err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("ipc.NewFileWriter: %v", err)
|
||||
}
|
||||
if err := w.Write(rec); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read temp arrow file: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDataframe_RoundTripCoreTypes pins down the Arrow-schema → internal
|
||||
// (type, format) mapping and the per-cell value shape that buildTypedCell
|
||||
// expects: number cells are json.Number (precision-preserving), date cells
|
||||
// are `yyyy-mm-dd` strings, bool/string come through verbatim. Numbers, dates,
|
||||
// strings, bools, and nulls all in one record so a future Arrow-Go bump can't
|
||||
// quietly regress any one family.
|
||||
func TestDataframe_RoundTripCoreTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "name", Type: arrow.BinaryTypes.String},
|
||||
{Name: "qty", Type: arrow.PrimitiveTypes.Int64},
|
||||
{Name: "price", Type: arrow.PrimitiveTypes.Float64, Metadata: arrow.NewMetadata(
|
||||
[]string{"number_format"}, []string{"$#,##0.00"},
|
||||
)},
|
||||
{Name: "active", Type: arrow.FixedWidthTypes.Boolean},
|
||||
{Name: "shipped_on", Type: arrow.FixedWidthTypes.Date32},
|
||||
}, nil)
|
||||
|
||||
jan15 := arrow.Date32FromTime(time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC))
|
||||
feb02 := arrow.Date32FromTime(time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).AppendValues([]string{"alice", ""}, []bool{true, false})
|
||||
b.Field(1).(*array.Int64Builder).AppendValues([]int64{42, 0}, []bool{true, false})
|
||||
b.Field(2).(*array.Float64Builder).AppendValues([]float64{19.95, 0}, []bool{true, false})
|
||||
b.Field(3).(*array.BooleanBuilder).AppendValues([]bool{true, false}, []bool{true, true})
|
||||
b.Field(4).(*array.Date32Builder).AppendValues([]arrow.Date32{jan15, feb02}, []bool{true, true})
|
||||
})
|
||||
|
||||
spec, err := decodeArrowToSheet(buf, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
if spec.Name != "S1" {
|
||||
t.Errorf("sheet name = %q, want S1", spec.Name)
|
||||
}
|
||||
if len(spec.Columns) != 5 {
|
||||
t.Fatalf("got %d columns, want 5", len(spec.Columns))
|
||||
}
|
||||
want := []struct{ typ, format string }{
|
||||
{"string", "@"},
|
||||
{"number", ""},
|
||||
{"number", "$#,##0.00"},
|
||||
{"bool", ""},
|
||||
{"date", "yyyy-mm-dd"},
|
||||
}
|
||||
for i, w := range want {
|
||||
if spec.Columns[i].Type != w.typ {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w.typ)
|
||||
}
|
||||
if spec.Columns[i].Format != w.format {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, w.format)
|
||||
}
|
||||
}
|
||||
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
// Row 0: every field present, types match what buildTypedCell will accept.
|
||||
row0 := spec.Rows[0]
|
||||
if row0[0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want \"alice\"", row0[0])
|
||||
}
|
||||
if n, ok := row0[1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", row0[1])
|
||||
}
|
||||
if n, ok := row0[2].(json.Number); !ok || n.String() != "19.95" {
|
||||
t.Errorf("row0[price] = %#v, want json.Number(\"19.95\")", row0[2])
|
||||
}
|
||||
if row0[3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", row0[3])
|
||||
}
|
||||
if row0[4] != "2024-01-15" {
|
||||
t.Errorf("row0[shipped_on] = %#v, want \"2024-01-15\"", row0[4])
|
||||
}
|
||||
|
||||
// Row 1: nulls on name/qty/price (despite the buffer values) must become nil
|
||||
// so buildTypedCell paints an empty cell that still carries number_format.
|
||||
row1 := spec.Rows[1]
|
||||
for _, c := range []int{0, 1, 2} {
|
||||
if row1[c] != nil {
|
||||
t.Errorf("row1[%d] = %#v, want nil (null in arrow)", c, row1[c])
|
||||
}
|
||||
}
|
||||
if row1[3] != false {
|
||||
t.Errorf("row1[active] = %#v, want false", row1[3])
|
||||
}
|
||||
if row1[4] != "2024-02-02" {
|
||||
t.Errorf("row1[shipped_on] = %#v, want \"2024-02-02\"", row1[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_Timestamp pins the timestamp → date conversion for the
|
||||
// timestamp[us] case (pandas default for `pd.Timestamp` columns once written
|
||||
// via `to_feather`). Only the calendar date matters for our `yyyy-mm-dd`
|
||||
// landing — guard against TZ drift from the wrong unit pick.
|
||||
func TestDataframe_Timestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "ts", Type: &arrow.TimestampType{Unit: arrow.Microsecond}},
|
||||
}, nil)
|
||||
ts := arrow.Timestamp(time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC).UnixMicro())
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.TimestampBuilder).AppendValues([]arrow.Timestamp{ts}, []bool{true})
|
||||
})
|
||||
spec, err := decodeArrowToSheet(buf, "S")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Columns[0].Type != "date" {
|
||||
t.Errorf("type = %q, want date", spec.Columns[0].Type)
|
||||
}
|
||||
if got := spec.Rows[0][0]; got != "2024-06-12" {
|
||||
t.Errorf("ts = %#v, want \"2024-06-12\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EmptySchema rejects an Arrow file whose schema has no fields:
|
||||
// a 0-column "DataFrame" would write a header-less, data-less block that
|
||||
// validates as "writer ran successfully" but produces nothing — the test ties
|
||||
// that off as an explicit error rather than letting it slip through.
|
||||
func TestDataframe_EmptySchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema(nil, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "no fields") {
|
||||
t.Errorf("err = %v, want 'no fields' error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DuplicateColumn catches duplicate-name columns at decode
|
||||
// time. Validate already rejects duplicate column names for the JSON path;
|
||||
// the Arrow path mirrors that so the error surfaces with the same shape.
|
||||
func TestDataframe_DuplicateColumn(t *testing.T) {
|
||||
t.Parallel()
|
||||
schema := arrow.NewSchema([]arrow.Field{
|
||||
{Name: "x", Type: arrow.BinaryTypes.String},
|
||||
{Name: "x", Type: arrow.PrimitiveTypes.Int64},
|
||||
}, nil)
|
||||
buf := buildArrowIPC(t, schema, func(b *array.RecordBuilder) {
|
||||
b.Field(0).(*array.StringBuilder).Append("")
|
||||
b.Field(1).(*array.Int64Builder).Append(0)
|
||||
})
|
||||
_, err := decodeArrowToSheet(buf, "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("err = %v, want duplicate-column error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BadBytes rejects a non-Arrow blob with a hint pointing at
|
||||
// pandas df.to_feather so users see what producer is expected without having
|
||||
// to grep the docs.
|
||||
func TestDataframe_BadBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := decodeArrowToSheet([]byte("not arrow"), "S")
|
||||
if err == nil || !strings.Contains(err.Error(), "Arrow") {
|
||||
t.Errorf("err = %v, want Arrow-decode error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeRoundTrip checks --dataframe-out's encoder against its
|
||||
// own decoder: build a +table-get-shaped sheet map (the same one
|
||||
// readSheetAsSpec emits), encode to Arrow IPC, decode back via the put-side
|
||||
// decoder, and require the column types / formats / row values to match. If
|
||||
// any encoder choice drifts from what the decoder expects, the round-trip
|
||||
// breaks here long before a real put → get round-trip in production would.
|
||||
func TestDataframe_EncodeRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
sheet := map[string]interface{}{
|
||||
"name": "S1",
|
||||
"columns": []interface{}{"name", "qty", "price", "active", "ts"},
|
||||
"dtypes": map[string]interface{}{
|
||||
"name": "object",
|
||||
"qty": "float64",
|
||||
"price": "float64",
|
||||
"active": "bool",
|
||||
"ts": "datetime64[ns]",
|
||||
},
|
||||
"formats": map[string]interface{}{
|
||||
// `@` is the writer convention for string columns; readSheetAsSpec
|
||||
// strips it via isTextNumberFormat, so an Arrow file built from a
|
||||
// real read won't carry @ either. Keep it absent here to mirror
|
||||
// the production wire shape.
|
||||
"price": "$#,##0.00",
|
||||
},
|
||||
"data": []interface{}{
|
||||
[]interface{}{"alice", json.Number("42"), json.Number("19.95"), true, "2024-01-15"},
|
||||
[]interface{}{"bob", nil, json.Number("8.5"), false, "2024-02-02"},
|
||||
},
|
||||
}
|
||||
blob, err := encodeSheetMapToArrowIPC(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("encodeSheetMapToArrowIPC: %v", err)
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S1")
|
||||
if err != nil {
|
||||
t.Fatalf("decodeArrowToSheet: %v", err)
|
||||
}
|
||||
wantTypes := []string{"string", "number", "number", "bool", "date"}
|
||||
wantFormats := []string{"@", "", "$#,##0.00", "", "yyyy-mm-dd"}
|
||||
if len(spec.Columns) != len(wantTypes) {
|
||||
t.Fatalf("got %d columns, want %d", len(spec.Columns), len(wantTypes))
|
||||
}
|
||||
for i, w := range wantTypes {
|
||||
if spec.Columns[i].Type != w {
|
||||
t.Errorf("columns[%d].Type = %q, want %q", i, spec.Columns[i].Type, w)
|
||||
}
|
||||
if spec.Columns[i].Format != wantFormats[i] {
|
||||
t.Errorf("columns[%d].Format = %q, want %q", i, spec.Columns[i].Format, wantFormats[i])
|
||||
}
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Fatalf("got %d rows, want 2", len(spec.Rows))
|
||||
}
|
||||
if spec.Rows[0][0] != "alice" {
|
||||
t.Errorf("row0[name] = %#v, want alice", spec.Rows[0][0])
|
||||
}
|
||||
if n, ok := spec.Rows[0][1].(json.Number); !ok || n.String() != "42" {
|
||||
t.Errorf("row0[qty] = %#v, want json.Number(\"42\")", spec.Rows[0][1])
|
||||
}
|
||||
if spec.Rows[0][3] != true {
|
||||
t.Errorf("row0[active] = %#v, want true", spec.Rows[0][3])
|
||||
}
|
||||
if spec.Rows[0][4] != "2024-01-15" {
|
||||
t.Errorf("row0[ts] = %#v, want 2024-01-15", spec.Rows[0][4])
|
||||
}
|
||||
// qty is null on row1, must come back as nil (not a zero-valued
|
||||
// json.Number that would later round-trip as 0).
|
||||
if spec.Rows[1][1] != nil {
|
||||
t.Errorf("row1[qty] = %#v, want nil (null arrow cell)", spec.Rows[1][1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_EncodeAcceptsBothRowShapes pins the encoder against the two
|
||||
// shapes `sheet["data"]` actually arrives in: `[][]interface{}` from a live
|
||||
// readSheetAsSpec call (production), and `[]interface{}` from a JSON
|
||||
// unmarshal (round-trip / fixtures). Either must produce non-empty Arrow
|
||||
// output — early on the production shape silently fell through the
|
||||
// `[]interface{}` type assertion and we shipped a 0-row Arrow blob.
|
||||
func TestDataframe_EncodeAcceptsBothRowShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := func(data interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": "S",
|
||||
"columns": []interface{}{"city"},
|
||||
"dtypes": map[string]interface{}{"city": "object"},
|
||||
"data": data,
|
||||
}
|
||||
}
|
||||
for label, data := range map[string]interface{}{
|
||||
"production [][]interface{}": [][]interface{}{{"BJ"}, {"SH"}},
|
||||
"unmarshal []interface{}": []interface{}{[]interface{}{"BJ"}, []interface{}{"SH"}},
|
||||
} {
|
||||
blob, err := encodeSheetMapToArrowIPC(base(data))
|
||||
if err != nil {
|
||||
t.Errorf("%s: encode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
spec, err := decodeArrowToSheet(blob, "S")
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode: %v", label, err)
|
||||
continue
|
||||
}
|
||||
if len(spec.Rows) != 2 {
|
||||
t.Errorf("%s: got %d rows, want 2", label, len(spec.Rows))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_DtypeToInternalType pins the inverse of typeToDtype so
|
||||
// readSheetAsSpec's dtype labels recover the right internal type. Covers the
|
||||
// dtype families +table-get emits today plus the safe fallback for unknown
|
||||
// labels (string, lossless).
|
||||
func TestDataframe_DtypeToInternalType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"float64": "number",
|
||||
"int64": "number",
|
||||
"Int64": "number",
|
||||
"bool": "bool",
|
||||
"boolean": "bool",
|
||||
"datetime64[ns]": "date",
|
||||
"datetime64[ms]": "date",
|
||||
"object": "string",
|
||||
"": "string",
|
||||
"weird-new-dtype": "string",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := dtypeToInternalType(in); got != want {
|
||||
t.Errorf("dtypeToInternalType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataframe_BytesWriterSeeker confirms the in-memory WriteSeeker handles
|
||||
// the Seek-and-overwrite pattern ipc.NewFileWriter uses to patch the footer
|
||||
// offset: write some bytes, seek back to the middle, overwrite, end up with
|
||||
// the buffer reflecting the overwritten bytes (not a tail-extended duplicate).
|
||||
func TestDataframe_BytesWriterSeeker(t *testing.T) {
|
||||
t.Parallel()
|
||||
var w bytesWriterSeeker
|
||||
if _, err := w.Write([]byte("hello world")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Seek(6, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte("WORLD")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(w.buf); got != "hello WORLD" {
|
||||
t.Errorf("buf = %q, want \"hello WORLD\"", got)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -473,18 +472,24 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
|
||||
t.Run("both set is rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--target-sheet-name", "Sheet1",
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
// 错误信息必须用真实的 flag 名(target-*),否则模型按消息提示去
|
||||
// 改 --sheet-id 还是错的。
|
||||
if !strings.Contains(ve.Message, "--target-sheet-id") {
|
||||
t.Errorf("expected error to quote --target-sheet-id flag name; got message=%q", ve.Message)
|
||||
if !strings.Contains(combined, "--target-sheet-id") {
|
||||
t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -514,7 +519,7 @@ func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
|
||||
t.Run("both non-default values rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
@@ -522,9 +527,15 @@ func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
"--target-position", "B5",
|
||||
"--range", "F1",
|
||||
})
|
||||
ve := requireValidation(t, err, "mutually exclusive")
|
||||
if !strings.Contains(ve.Message, "--target-position") || !strings.Contains(ve.Message, "--range") {
|
||||
t.Errorf("expected error to quote both --target-position and --range; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject --target-position with --range; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "mutually exclusive") {
|
||||
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
|
||||
}
|
||||
if !strings.Contains(combined, "--target-position") || !strings.Contains(combined, "--range") {
|
||||
t.Errorf("expected error to quote both --target-position and --range; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -557,27 +568,35 @@ func TestPivotCreate_SchemaValidates(t *testing.T) {
|
||||
|
||||
t.Run("rejects wrong type for rows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"rows":"not-an-array"}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "rows")
|
||||
if !strings.Contains(ve.Message, "array") {
|
||||
t.Errorf("expected error to mention array; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") {
|
||||
t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects out-of-enum summarize_by", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "summarize_by")
|
||||
if err == nil {
|
||||
t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr+err.Error(), "summarize_by") {
|
||||
t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("schema-conformant input is accepted", func(t *testing.T) {
|
||||
@@ -611,8 +630,14 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
requireValidation(t, err, "specify at least one of --sheet-id or --sheet-name")
|
||||
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
|
||||
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -623,13 +648,19 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
|
||||
// +sparkline-list, before any server call goes out.
|
||||
func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
|
||||
"--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`,
|
||||
})
|
||||
ve := requireValidation(t, err, "missing sparkline_id")
|
||||
if !strings.Contains(ve.Message, "+sparkline-list") {
|
||||
t.Errorf("expected error to point at +sparkline-list; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr)
|
||||
}
|
||||
combined := stderr + err.Error()
|
||||
if !strings.Contains(combined, "missing sparkline_id") {
|
||||
t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err)
|
||||
}
|
||||
if !strings.Contains(combined, "+sparkline-list") {
|
||||
t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,7 +729,12 @@ func TestCondFormatAttrs_ShapeMatchesRuleType(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if tt.wantErr {
|
||||
requireValidation(t, err, tt.wantMsg)
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection; stderr=%s", stderr)
|
||||
}
|
||||
if combined := stderr + err.Error(); tt.wantMsg != "" && !strings.Contains(combined, tt.wantMsg) {
|
||||
t.Errorf("expected error to mention %q; got=%s|%v", tt.wantMsg, stderr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -815,13 +851,18 @@ func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
|
||||
// create still mandates one of --image / --image-token / --image-uri.
|
||||
func TestFloatImageCreate_RequiresImageSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
|
||||
_, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--image-name", "x.png",
|
||||
"--position-row", "0", "--position-col", "A",
|
||||
"--size-width", "10", "--size-height", "10",
|
||||
})
|
||||
requireValidation(t, err, "one of --image, --image-token, or --image-uri is required")
|
||||
if err == nil {
|
||||
t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr)
|
||||
}
|
||||
if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") {
|
||||
t.Errorf("expected error to require an image source; got=%s|%v", stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks
|
||||
@@ -844,8 +885,14 @@ func TestObjectDelete_AllHighRisk(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "")
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
|
||||
t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +540,7 @@ func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *
|
||||
|
||||
func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error {
|
||||
return func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -287,11 +287,16 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, RangeSort, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, c.want)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
|
||||
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -344,8 +349,13 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
requireValidation(t, err, tt.want)
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +81,15 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
// every other get_cell_ranges wrapper uses.
|
||||
func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, DropdownGet, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DropdownGet, []string{
|
||||
"--url", testURL, "--range", "A2:A100", "--dry-run",
|
||||
})
|
||||
ve := requireValidation(t, err, "")
|
||||
if !strings.Contains(ve.Message, "sheet-id") && !strings.Contains(ve.Message, "sheet-name") {
|
||||
t.Errorf("expected --sheet-id/--sheet-name guard; got message=%q", ve.Message)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "sheet-id") && !strings.Contains(combined, "sheet-name") {
|
||||
t.Errorf("expected --sheet-id/--sheet-name guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +109,15 @@ func TestReadData_RequiresRange(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, c.sc, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, c.sc, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--range", " ", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "--range is required")
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "--range is required") {
|
||||
t.Errorf("expected --range guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,17 +89,14 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
|
||||
|
||||
func TestCellsReplace_RequireFlag(t *testing.T) {
|
||||
t.Parallel()
|
||||
// --replace not passed at all (vs empty string) should error. This trips
|
||||
// cobra's required-flag gate before our Validate hook runs, so the error
|
||||
// is cobra's plain `required flag(s) "replacement" not set` rather than a
|
||||
// typed *errs.ValidationError — keep this assertion as a substring check.
|
||||
// --replace not passed at all (vs empty string) should error.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when --replacement omitted; stdout=%s stderr=%s", stdout, stderr)
|
||||
t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "replacement") {
|
||||
t.Errorf("expected message about --replacement; got=%s|%s|%v", stdout, stderr, err)
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "replace") {
|
||||
t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +198,13 @@ func TestDimRange_Validation(t *testing.T) {
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, DimHide, tt.args)
|
||||
requireValidation(t, err, tt.want)
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DimHide, tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
|
||||
t.Errorf("expected %q substring; got=%s|%s|%v", tt.want, stdout, stderr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -264,11 +269,16 @@ func TestDimMove_Column(t *testing.T) {
|
||||
// column (or vice versa) is rejected at Validate.
|
||||
func TestDimMove_MismatchedDimension(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := runShortcutCapturingErr(t, DimMove, []string{
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, DimMove, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--source-range", "1:3", "--target", "H", "--dry-run",
|
||||
})
|
||||
requireValidation(t, err, "must match --source-range")
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout+stderr+err.Error(), "must match --source-range") {
|
||||
t.Errorf("expected dimension-mismatch guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseA1Range covers parser edge cases directly.
|
||||
|
||||
@@ -58,7 +58,12 @@ func TestNormalizeCellStyleAliases(t *testing.T) {
|
||||
"horizontal_alignment": "left",
|
||||
}
|
||||
err := normalizeCellStyleAliases(style, "cell_styles[0]")
|
||||
requireValidation(t, err, "conflicts with horizontal_alignment")
|
||||
if err == nil {
|
||||
t.Fatalf("expected conflict error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflicts with horizontal_alignment") {
|
||||
t.Errorf("error should name the conflict: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no shorthand leaves the map untouched", func(t *testing.T) {
|
||||
@@ -137,7 +142,12 @@ func TestNormalizeTypedCellsStyleAliases(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := normalizeTypedCellsStyleAliases(cells, "--cells")
|
||||
requireValidation(t, err, "--cells[0][0].cell_styles")
|
||||
if err == nil {
|
||||
t.Fatalf("expected conflict error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cells[0][0].cell_styles") {
|
||||
t.Errorf("error should carry the cell path: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,7 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -87,18 +85,29 @@ var TablePut = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
|
||||
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
|
||||
"Two equivalent producers: --sheets (multi-sheet JSON, the pandas-split convention) or --dataframe (single-sheet Arrow IPC binary, what `df.to_feather()` writes). Mutually exclusive; pick by what your producer already emits.",
|
||||
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
|
||||
},
|
||||
}
|
||||
|
||||
// resolveTablePayload parses --sheets (typed JSON, multi-sheet) into the
|
||||
// resolveTablePayload picks between --sheets (JSON, multi-sheet) and
|
||||
// --dataframe (Arrow IPC, single-sheet), enforces XOR, and returns the
|
||||
// unified internal tablePayload. Both +table-put and +workbook-create funnel
|
||||
// through here so the two entry points stay in lockstep; Validate / Execute /
|
||||
// DryRun / workbookCreateData all share this one decision. Network-free.
|
||||
func resolveTablePayload(rctx *common.RuntimeContext) (*tablePayload, error) {
|
||||
sheetsGiven := rctx.Changed("sheets") && strings.TrimSpace(rctx.Str("sheets")) != ""
|
||||
if !sheetsGiven {
|
||||
return nil, common.ValidationErrorf("--sheets is required")
|
||||
dfGiven := rctx.Changed("dataframe") && strings.TrimSpace(rctx.Str("dataframe")) != ""
|
||||
if sheetsGiven && dfGiven {
|
||||
return nil, common.ValidationErrorf("--sheets and --dataframe are mutually exclusive")
|
||||
}
|
||||
if !sheetsGiven && !dfGiven {
|
||||
// Mirror the original "--sheets is required" message but list both
|
||||
// alternatives so users discover the binary entry from the error.
|
||||
return nil, common.ValidationErrorf("one of --sheets or --dataframe is required")
|
||||
}
|
||||
if dfGiven {
|
||||
return parseDataframePayload(rctx)
|
||||
}
|
||||
return parseTablePutPayload(rctx)
|
||||
}
|
||||
@@ -223,21 +232,6 @@ func typeToDtype(typ string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// decoderExpectEOF ensures the decoder has nothing left to read after a
|
||||
// successful Decode. json.Decoder accepts trailing non-whitespace after the
|
||||
// first JSON value (unlike json.Unmarshal), so a payload like `{...} trailing`
|
||||
// would silently be treated as the leading object only. Use this after the
|
||||
// first Decode to surface the trailing data as a validation error.
|
||||
func decoderExpectEOF(dec *json.Decoder) error {
|
||||
var trailing json.RawMessage
|
||||
if err := dec.Decode(&trailing); err == nil {
|
||||
return fmt.Errorf("trailing data after JSON value") //nolint:forbidigo // intermediate error; the caller wraps it into a typed --sheets/--values validation error
|
||||
} else if !errors.Is(err, io.EOF) {
|
||||
return fmt.Errorf("trailing data after JSON value: %w", err) //nolint:forbidigo // intermediate error; the caller wraps it into a typed --sheets/--values validation error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
|
||||
// validated payload. UseNumber keeps numeric cells as json.Number so large
|
||||
// integers (order IDs, etc.) survive without precision loss or scientific
|
||||
@@ -256,13 +250,7 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
|
||||
Sheets []tableSheetIn `json:"sheets"`
|
||||
}
|
||||
if err := dec.Decode(&wire); err != nil {
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
|
||||
}
|
||||
// Reject trailing non-whitespace after the first JSON value: json.Decoder
|
||||
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
|
||||
// would otherwise pass Validate and surface as confusing downstream errors.
|
||||
if err := decoderExpectEOF(dec); err != nil {
|
||||
return nil, common.ValidationErrorf("--sheets: %v", err).WithCause(err)
|
||||
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err)
|
||||
}
|
||||
p := &tablePayload{Sheets: make([]tableSheetSpec, 0, len(wire.Sheets))}
|
||||
for i := range wire.Sheets {
|
||||
@@ -367,7 +355,7 @@ func (p *tablePayload) validate() error {
|
||||
// stray empty spreadsheet behind.
|
||||
for c := range s.Columns {
|
||||
if _, err := buildTypedCell(&s.Columns[c], s.Rows[r][c]); err != nil {
|
||||
return common.ValidationErrorf("--sheets[%d] %q: row %d column %q: %v", i, s.Name, r, s.Columns[c].Name, err).WithCause(err)
|
||||
return common.ValidationErrorf("--sheets[%d] %q: row %d column %q: %v", i, s.Name, r, s.Columns[c].Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,7 +419,7 @@ func buildSheetMatrix(s *tableSheetSpec, writeHeader bool) ([][]interface{}, err
|
||||
for c := range s.Columns {
|
||||
cell, err := buildTypedCell(&s.Columns[c], s.Rows[r][c])
|
||||
if err != nil {
|
||||
return nil, common.ValidationErrorf("sheet %q row %d column %q: %v", s.Name, r, s.Columns[c].Name, err).WithCause(err)
|
||||
return nil, common.ValidationErrorf("sheet %q row %d column %q: %v", s.Name, r, s.Columns[c].Name, err)
|
||||
}
|
||||
row[c] = cell
|
||||
}
|
||||
@@ -563,7 +551,7 @@ func isoDateToSerial(s string) (int, error) {
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("date %q must be ISO yyyy-mm-dd: %w", s, err) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||||
return 0, fmt.Errorf("date %q must be ISO yyyy-mm-dd: %v", s, err) //nolint:forbidigo // intermediate error; callers wrap it into a typed --sheets/--values validation error with row/column context
|
||||
}
|
||||
return int(math.Round(t.Sub(excelEpoch).Hours() / 24)), nil
|
||||
}
|
||||
@@ -591,13 +579,8 @@ func sheetAnchor(s *tableSheetSpec) (anchor string, col0, row0 int, err error) {
|
||||
}
|
||||
|
||||
// tablePutFullRange is the A1 rectangle the whole matrix (header + data)
|
||||
// occupies, for reporting in the result / dry-run. Returns "" when there is
|
||||
// nothing to write (e.g. header=false with no data rows) — the previous
|
||||
// formula would have produced an invalid trailing row like "A1:C0".
|
||||
// occupies, for reporting in the result / dry-run.
|
||||
func tablePutFullRange(s *tableSheetSpec, totalRows int) string {
|
||||
if totalRows <= 0 || len(s.Columns) == 0 {
|
||||
return ""
|
||||
}
|
||||
_, col0, row0, err := sheetAnchor(s)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(s.StartCell)
|
||||
@@ -655,6 +638,16 @@ func writeSheetData(ctx context.Context, runtime *common.RuntimeContext, token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Grow the sub-sheet to fit the write block before the first batch.
|
||||
// Without this, writes past the sheet's initial dimensions (typically the
|
||||
// backend default of 200 rows × 20 cols — which also covers
|
||||
// `+workbook-create`'s adopted Sheet1) fail with [900015206] range exceeds
|
||||
// sheet bounds. Best-effort: if reading dims fails the downstream write
|
||||
// will surface the same out-of-bounds error it did before this helper.
|
||||
if err := ensureSheetCapacity(ctx, runtime, token, sheetID, baseRow+len(matrix), col0+ncols); err != nil {
|
||||
return nil, fmt.Errorf("ensuring sheet capacity: %w", err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||||
}
|
||||
|
||||
startCol := columnIndexToLetter(col0)
|
||||
endCol := columnIndexToLetter(col0 + ncols - 1)
|
||||
allowOverwrite := s.AllowOverwrite == nil || *s.AllowOverwrite
|
||||
@@ -871,15 +864,7 @@ func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
|
||||
_, col0, row0, _ := sheetAnchor(s)
|
||||
cols = col0 + len(s.Columns)
|
||||
rows = row0 + len(s.Rows)
|
||||
// Match writeSheetData's header decision exactly. headerOn() is false for
|
||||
// append mode by default, but writeSheetData *forces* a header when append
|
||||
// hits an empty sheet with no explicit Header choice (so column names
|
||||
// aren't lost on the first append). sheetCreateDims runs only when the
|
||||
// sheet is being created — therefore the sheet IS empty and that forced
|
||||
// header WILL be written, so size for it here. Without this the sheet is
|
||||
// created one row short and append-near-50000 / append-at-N-cols-200
|
||||
// would bounce off the backend's hard cap.
|
||||
if headerOn(s) || (s.Mode == "append" && s.Header == nil) {
|
||||
if headerOn(s) {
|
||||
rows++
|
||||
}
|
||||
if cols < 20 {
|
||||
@@ -897,6 +882,91 @@ func sheetCreateDims(s *tableSheetSpec) (rows, cols int) {
|
||||
return rows, cols
|
||||
}
|
||||
|
||||
// ensureSheetCapacity grows the sub-sheet's row / column count enough to
|
||||
// fit a needRows × needCols write block before the next set_cell_range call.
|
||||
// Both +table-put writing into an existing sheet *and* +workbook-create's
|
||||
// adopted default Sheet1 inherit the backend's 200×20 default — anything
|
||||
// past that would error with `[900015206] range exceeds sheet bounds`.
|
||||
// Read failures (e.g. mock stubs without `row_count`) silently fall through;
|
||||
// the downstream write surfaces the same out-of-bounds error it did before,
|
||||
// so the helper can't make things worse. Backend hard ceilings (50000 rows,
|
||||
// 200 cols) are honored.
|
||||
func ensureSheetCapacity(ctx context.Context, runtime *common.RuntimeContext, token, sheetID string, needRows, needCols int) error {
|
||||
if needRows <= 0 && needCols <= 0 {
|
||||
return nil
|
||||
}
|
||||
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_sheet_structure", map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_id": sheetID,
|
||||
})
|
||||
if err != nil {
|
||||
// best-effort: skip if we can't see current dims (mock without stub,
|
||||
// permissions, transient failure). The write below will still bounce
|
||||
// if the sheet really is too small, so degrading silently here is
|
||||
// fail-open by design.
|
||||
return nil //nolint:nilerr
|
||||
}
|
||||
m, _ := out.(map[string]interface{})
|
||||
// get_sheet_structure reports current dims as the `range` field
|
||||
// (e.g. "A1:T200" → 20 cols × 200 rows). splitCellRef parses the
|
||||
// bottom-right corner into 0-based (col, row).
|
||||
curRows, curCols := 0, 0
|
||||
if rng, _ := m["range"].(string); rng != "" {
|
||||
parts := strings.SplitN(rng, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
if c, r, ok := splitCellRef(parts[1]); ok {
|
||||
curCols = c + 1
|
||||
curRows = r + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if needRows > curRows && curRows > 0 {
|
||||
target := needRows
|
||||
if target > 50000 {
|
||||
target = 50000
|
||||
}
|
||||
if target > curRows {
|
||||
// position is 1-based and must be ≤ curRows (the backend rejects
|
||||
// "before row N+1" as out-of-range). Inserting before the last
|
||||
// existing row pushes that row down and effectively appends
|
||||
// `count` blank rows — the data writes that follow will overwrite
|
||||
// the existing rows from row 1, so the placement of the inserted
|
||||
// blanks doesn't matter as long as the total dimension grows.
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_id": sheetID,
|
||||
"operation": "insert",
|
||||
"position": strconv.Itoa(curRows),
|
||||
"count": target - curRows,
|
||||
}
|
||||
if _, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input); err != nil {
|
||||
return fmt.Errorf("growing rows %d → %d: %w", curRows, target, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||||
}
|
||||
}
|
||||
}
|
||||
if needCols > curCols && curCols > 0 {
|
||||
target := needCols
|
||||
if target > 200 {
|
||||
target = 200
|
||||
}
|
||||
if target > curCols {
|
||||
// Same 1-based, ≤-current constraint as rows: insert before the
|
||||
// last existing column letter.
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"sheet_id": sheetID,
|
||||
"operation": "insert",
|
||||
"position": columnIndexToLetter(curCols - 1),
|
||||
"count": target - curCols,
|
||||
}
|
||||
if _, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input); err != nil {
|
||||
return fmt.Errorf("growing cols %d → %d: %w", curCols, target, err) //nolint:forbidigo // intermediate error; surfaced as a partial_success message string via tablePutPartial, not a typed final error
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gridDims is a sub-sheet's physical grid size (row_count × column_count from
|
||||
// get_workbook_structure). A zero in either field means "unknown", which makes
|
||||
// the used-range probes (sheetCurrentRegion / lastDataRow) fall back to their
|
||||
@@ -1033,6 +1103,15 @@ var TableGet = common.Shortcut{
|
||||
if strings.TrimSpace(runtime.Str("sheet-id")) != "" && strings.TrimSpace(runtime.Str("sheet-name")) != "" {
|
||||
return common.ValidationErrorf("--sheet-id and --sheet-name are mutually exclusive")
|
||||
}
|
||||
// --dataframe-out is Arrow IPC, which carries one schema per file — a
|
||||
// whole-workbook read can't ride that shape. Surface the constraint
|
||||
// before we round-trip to the API instead of after the read fails to
|
||||
// encode.
|
||||
if strings.TrimSpace(runtime.Str("dataframe-out")) != "" {
|
||||
if strings.TrimSpace(runtime.Str("sheet-id")) == "" && strings.TrimSpace(runtime.Str("sheet-name")) == "" {
|
||||
return common.ValidationErrorf("--dataframe-out requires --sheet-id or --sheet-name (single-sheet only); for the whole workbook, drop --dataframe-out and use the default JSON output")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -1047,19 +1126,10 @@ var TableGet = common.Shortcut{
|
||||
if rng == "" {
|
||||
rng = "<each sheet's used range (full-grid current_region)>"
|
||||
}
|
||||
// Mirror the selector the Execute path will pass to get_cell_ranges so the
|
||||
// dry-run body matches the real request shape; agents that validate one and
|
||||
// run the other would otherwise see a sheet_id/sheet_name field appear out
|
||||
// of nowhere. Network-free: only echoes the flags the caller already gave.
|
||||
input := map[string]interface{}{
|
||||
body, _ = buildToolBody("get_cell_ranges", map[string]interface{}{
|
||||
"excel_id": token, "ranges": []string{rng},
|
||||
"include_styles": true, "value_render_option": "raw_value",
|
||||
}
|
||||
sheetSelectorForToolInput(input,
|
||||
strings.TrimSpace(runtime.Str("sheet-id")),
|
||||
strings.TrimSpace(runtime.Str("sheet-name")),
|
||||
)
|
||||
body, _ = buildToolBody("get_cell_ranges", input)
|
||||
})
|
||||
dry.POST(toolInvokePath(token, ToolKindRead)).
|
||||
Desc(fmt.Sprintf("read cells (%s) + styles via get_cell_ranges, then infer column types", rng)).
|
||||
Body(body)
|
||||
@@ -1084,12 +1154,38 @@ var TableGet = common.Shortcut{
|
||||
}
|
||||
sheets = append(sheets, spec)
|
||||
}
|
||||
// Arrow IPC binary branch: Validate already guards single-sheet so the
|
||||
// sheets slice has exactly one entry here. Stdout mode owns stdout for
|
||||
// the binary stream — no envelope, raw Arrow only. File mode writes
|
||||
// the Arrow blob to disk and still emits the lark-cli JSON envelope
|
||||
// (output_path / bytes / sheet_name) so scripted callers can detect
|
||||
// success the same way they do for every other shortcut.
|
||||
if dfOut := strings.TrimSpace(runtime.Str("dataframe-out")); dfOut != "" {
|
||||
spec, _ := sheets[0].(map[string]interface{})
|
||||
data, err := encodeSheetMapToArrowIPC(spec)
|
||||
if err != nil {
|
||||
return common.ValidationErrorf("--dataframe-out: encode arrow: %v", err)
|
||||
}
|
||||
if err := writeDataframeOut(runtime, dfOut, data); err != nil {
|
||||
return err
|
||||
}
|
||||
if dfOut == "-" {
|
||||
return nil
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"output_path": strings.TrimPrefix(dfOut, "@"),
|
||||
"bytes": len(data),
|
||||
"sheet_name": spec["name"],
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
|
||||
return nil
|
||||
},
|
||||
Tips: []string{
|
||||
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
|
||||
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
|
||||
"For a pandas round-trip, use --dataframe-out (single sheet, Arrow IPC / Feather v2) — `@./x.arrow` writes a file, `-` streams binary to stdout for `pd.read_feather(BytesIO(stdout))`. Multi-sheet reads stay on the JSON path.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1123,11 +1219,8 @@ func tableGetTargets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
// Single-sheet selector path can degrade gracefully without dimensions
|
||||
// (the probe falls back to the A1 anchor); the whole-workbook path can't
|
||||
// enumerate sheets without the structure, so it must surface the error.
|
||||
// Name doubles as id for --sheet-id so the output spec is never nameless
|
||||
// (an empty name would break +table-get → +table-put round-trip — the
|
||||
// writer requires a non-empty sheet name).
|
||||
if id != "" {
|
||||
return []tableGetSheet{{id: id, name: id}}, nil
|
||||
return []tableGetSheet{{id: id}}, nil
|
||||
}
|
||||
if name != "" {
|
||||
return []tableGetSheet{{name: name}}, nil
|
||||
@@ -1140,8 +1233,7 @@ func tableGetTargets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
// Selector path: find the matching sheet to pick up its dimensions (and
|
||||
// backfill name when only --sheet-id was given). If no row matches, fall
|
||||
// back to a dimensionless target so the read still proceeds via the A1
|
||||
// anchor rather than erroring on a structure/selector mismatch — same
|
||||
// id-as-name backfill applies so the output spec round-trips.
|
||||
// anchor rather than erroring on a structure/selector mismatch.
|
||||
if id != "" || name != "" {
|
||||
for _, r := range raw {
|
||||
sid, sname, rc, cc := tableGetSheetMeta(r)
|
||||
@@ -1150,7 +1242,7 @@ func tableGetTargets(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
}
|
||||
}
|
||||
if id != "" {
|
||||
return []tableGetSheet{{id: id, name: id}}, nil
|
||||
return []tableGetSheet{{id: id}}, nil
|
||||
}
|
||||
return []tableGetSheet{{name: name}}, nil
|
||||
}
|
||||
@@ -1257,25 +1349,10 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
|
||||
colTypes := make([]string, ncols)
|
||||
dtypes := make(map[string]interface{}, ncols)
|
||||
formats := map[string]interface{}{}
|
||||
// Duplicate header names break +table-get → +table-put round-trip: the dtypes
|
||||
// map (keyed by name) silently collapses to a single entry and the writer
|
||||
// later rejects the duplicate columns in --sheets validation. Fail fast with
|
||||
// an actionable hint when the source sheet actually has duplicate headers.
|
||||
// noHeader mode is exempt because tableGetColumnName falls back to positional
|
||||
// col<N> names which are always unique.
|
||||
seenNames := map[string]int{}
|
||||
for c := 0; c < ncols; c++ {
|
||||
typ, format := inferColumnType(dataRows, c)
|
||||
colTypes[c] = typ
|
||||
name := tableGetColumnName(headerRow, c, noHeader)
|
||||
if !noHeader {
|
||||
if prev, dup := seenNames[name]; dup {
|
||||
return nil, common.ValidationErrorf(
|
||||
"sheet %q: duplicate header column name %q at columns %d and %d; this would break the +table-get → +table-put round-trip. Rename the headers or pass --no-header to read by position (col1/col2/…).",
|
||||
t.name, name, prev+1, c+1)
|
||||
}
|
||||
seenNames[name] = c
|
||||
}
|
||||
columnNames[c] = name
|
||||
dtypes[name] = typeToDtype(typ)
|
||||
// Only emit a format when the column actually has one and it's not the
|
||||
@@ -1468,42 +1545,10 @@ func inferColumnType(dataRows [][]map[string]interface{}, c int) (string, string
|
||||
}
|
||||
|
||||
// isDateNumberFormat reports whether a number_format denotes a date/time. Date
|
||||
// formats carry a year token (Excel's 'yy' or 'yyyy'); pure numeric formats
|
||||
// (#,##0, 0.00, 0.00%, @) do not.
|
||||
//
|
||||
// Token-aware so currency / unit prefixes that happen to contain a lone 'y' or
|
||||
// 'Y' — most notably "JPY #,##0" — are not misread as dates. The scanner skips:
|
||||
// - characters inside double-quoted literals ("Yen ")
|
||||
// - the character following a backslash escape (\y)
|
||||
// - characters inside [...] sections ([Red], [$EUR-2])
|
||||
//
|
||||
// and only fires on an unquoted/unescaped/unbracketed 'yy' (a single 'y' is
|
||||
// not a year token in Excel; "JPY 0" has 'Y' but never 'yy').
|
||||
// formats carry a year token ('y'); pure numeric formats (#,##0, 0.00, 0.00%,
|
||||
// @) do not.
|
||||
func isDateNumberFormat(nf string) bool {
|
||||
s := strings.ToLower(nf)
|
||||
inQuote, inBracket, escape := false, false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if escape {
|
||||
escape = false
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c == '\\':
|
||||
escape = true
|
||||
case !inBracket && c == '"':
|
||||
inQuote = !inQuote
|
||||
case !inQuote && c == '[':
|
||||
inBracket = true
|
||||
case !inQuote && c == ']':
|
||||
inBracket = false
|
||||
case !inQuote && !inBracket:
|
||||
if c == 'y' && i+1 < len(s) && s[i+1] == 'y' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return strings.ContainsRune(strings.ToLower(nf), 'y')
|
||||
}
|
||||
|
||||
// isTextNumberFormat reports whether a number_format is Excel/Lark text format
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user