mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:37:51 +08:00
Compare commits
63 Commits
feat/slide
...
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 |
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`
|
||||
|
||||
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`
|
||||
@@ -347,89 +347,6 @@ func boolIntQueryMethod(required bool) meta.Method {
|
||||
})
|
||||
}
|
||||
|
||||
func slidesSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "slides",
|
||||
"servicePath": "/open-apis/slides_ai/v1",
|
||||
})
|
||||
}
|
||||
|
||||
func slidesXMLPresentationSlideGetMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "xml_presentations/{xml_presentation_id}/slide",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"xml_presentation_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"slide_id": map[string]interface{}{"type": "string", "location": "query"},
|
||||
"slide_number": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func slidesXMLPresentationsGetMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "xml_presentations/{xml_presentation_id}",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"xml_presentation_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"revision_id": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
"remove_attr_id": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlidesXMLPresentationsGet_RemoveAttrID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationsGetMethod(), "get", "xml_presentations", nil)
|
||||
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--remove-attr-id", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"/open-apis/slides_ai/v1/xml_presentations/pid_123", `"remove_attr_id": true`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLPresentationSlideGet_BySlideNumber(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationSlideGetMethod(), "get", "xml_presentation.slide", nil)
|
||||
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--slide-number", "2", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"/open-apis/slides_ai/v1/xml_presentations/pid_123/slide", `"slide_number": 2`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "slide_id") {
|
||||
t.Errorf("slide_number-only request should not include slide_id, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLPresentationSlideGet_SlideIDWinsOverSlideNumber(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationSlideGetMethod(), "get", "xml_presentation.slide", nil)
|
||||
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--slide-id", "sld_123", "--slide-number", "2", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"slide_id": "sld_123"`) {
|
||||
t.Errorf("expected slide_id in dry-run output, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "slide_number") {
|
||||
t.Errorf("slide_id should take precedence over slide_number, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
|
||||
// so --flag=false / --flag 0 are real values and must be sent — not silently
|
||||
// dropped as "empty", which would let the API default win over an explicit
|
||||
|
||||
@@ -566,7 +566,6 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
for name, value := range params {
|
||||
queryParams[name] = value
|
||||
}
|
||||
normalizeServiceQueryParams(opts.SchemaPath, queryParams)
|
||||
|
||||
request := client.RawApiRequest{
|
||||
Method: httpMethod,
|
||||
@@ -624,15 +623,6 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func normalizeServiceQueryParams(schemaPath string, queryParams map[string]interface{}) {
|
||||
if schemaPath != "slides.xml_presentation.slide.get" {
|
||||
return
|
||||
}
|
||||
if slideID, ok := queryParams["slide_id"]; ok && !unusableParamValue(slideID) {
|
||||
delete(queryParams, "slide_number")
|
||||
}
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
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。
|
||||
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=
|
||||
|
||||
@@ -75,8 +75,6 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set("x-tt-env", "ppe_pdf2slide")
|
||||
h.Set("x-use-ppe", "1")
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu-pre.cn"
|
||||
Accounts string // e.g. "https://accounts.feishu-pre.cn"
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
Accounts string // e.g. "https://accounts.feishu.cn"
|
||||
MCP string // e.g. "https://mcp.feishu.cn"
|
||||
AppLink string // e.g. "https://applink.feishu-pre.cn"
|
||||
AppLink string // e.g. "https://applink.feishu.cn"
|
||||
}
|
||||
|
||||
// ResolveEndpoints resolves endpoint URLs based on brand.
|
||||
@@ -48,10 +48,10 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
}
|
||||
default:
|
||||
return Endpoints{
|
||||
Open: "https://open.feishu-pre.cn",
|
||||
Accounts: "https://accounts.feishu-pre.cn",
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
AppLink: "https://applink.feishu-pre.cn",
|
||||
AppLink: "https://applink.feishu.cn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,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)
|
||||
@@ -1051,7 +1045,8 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
}
|
||||
if stdinUsed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + 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)
|
||||
}
|
||||
stdinUsed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
@@ -1166,7 +1161,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
hints = append(hints, "@file")
|
||||
}
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
hints = append(hints, "- for stdin")
|
||||
// "- reads stdin" intentionally avoids implying each flag has
|
||||
// its own stdin: a process has a single stdin, so at most one
|
||||
// flag per call may use "-" (the rest must use @file). The old
|
||||
// per-flag "- for stdin" wording led AI agents to write
|
||||
// `--a - <x --b - <y`, where the second `<` silently clobbers
|
||||
// the first and `--a` reads the wrong payload.
|
||||
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
|
||||
}
|
||||
desc += " (supports " + strings.Join(hints, ", ") + ")"
|
||||
}
|
||||
@@ -1182,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"
|
||||
)
|
||||
|
||||
@@ -59,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,10 +216,15 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
vErr := assertValidationParam(t, err, "--b")
|
||||
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.
|
||||
if !strings.Contains(vErr.Hint, "@file") {
|
||||
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripUTF8BOM(t *testing.T) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,236 +40,302 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
})
|
||||
return ValidateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OnlySchema bool
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
OnlySchema: p.OnlySchema,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateExport runs the CLI-level export constraint checks.
|
||||
func ValidateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -497,6 +497,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
|
||||
@@ -28,139 +28,171 @@ var DriveImport = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -45,7 +45,6 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"csv": {"sheet", "bitable"},
|
||||
"base": {"bitable"},
|
||||
"pptx": {"slides"},
|
||||
"pdf": {"slides"},
|
||||
}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
@@ -154,7 +153,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "pptx", "pdf":
|
||||
case "pptx":
|
||||
return driveImport500MBFileSizeLimit, true
|
||||
case "txt", "md", "mark", "markdown", "html", "xls", "base":
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
@@ -200,7 +199,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
|
||||
func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
ext := spec.FileExtension()
|
||||
if ext == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
@@ -211,7 +210,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
|
||||
supportedTypes, ok := driveImportExtToDocTypes[ext]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
|
||||
}
|
||||
|
||||
typeAllowed := false
|
||||
@@ -232,8 +231,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
|
||||
case "base":
|
||||
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
|
||||
case "pptx", "pdf":
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
|
||||
case "pptx":
|
||||
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
|
||||
default:
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
|
||||
}
|
||||
|
||||
@@ -41,10 +41,6 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
name: "pptx slides ok",
|
||||
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
|
||||
},
|
||||
{
|
||||
name: "pdf slides ok",
|
||||
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
|
||||
},
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
|
||||
@@ -55,11 +51,6 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
|
||||
wantErr: ".pptx files can only be imported as 'slides'",
|
||||
},
|
||||
{
|
||||
name: "pdf non slides rejected",
|
||||
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
|
||||
wantErr: ".pdf files can only be imported as 'slides'",
|
||||
},
|
||||
{
|
||||
name: "unknown extension rejected",
|
||||
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
|
||||
@@ -147,19 +138,6 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "pdf exceeds 500mb limit",
|
||||
filePath: "./deck.pdf",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 500.0 MB import limit for .pdf",
|
||||
},
|
||||
{
|
||||
name: "pdf within 500mb limit",
|
||||
filePath: "./deck.pdf",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "base exceeds 20mb limit",
|
||||
filePath: "./snapshot.base",
|
||||
|
||||
@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
|
||||
@@ -150,6 +150,12 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Insert position; appended to the end when omitted",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -413,6 +413,86 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -431,27 +511,45 @@
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"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": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"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",
|
||||
@@ -513,6 +611,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-import": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "file",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Local file path (.xlsx / .xls / .csv)"
|
||||
},
|
||||
{
|
||||
"name": "folder-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; imported to the cloud drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -1082,9 +1206,8 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Safety cap; default 200000",
|
||||
"default": "200000",
|
||||
"hidden": true
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "skip-hidden",
|
||||
@@ -1192,9 +1315,8 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Safety cap; default 200000",
|
||||
"default": "200000",
|
||||
"hidden": true
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "include-row-prefix",
|
||||
@@ -1212,19 +1334,72 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "rows-json",
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by id); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"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": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
"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",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1849,7 +2024,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1880,6 +2055,61 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"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": "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: 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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": "2",
|
||||
"schema_version": "3",
|
||||
"flags": {
|
||||
"+batch-update": {
|
||||
"operations": {
|
||||
@@ -454,7 +454,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"link": {
|
||||
"description": "超链接地址(仅 type='link' 时必填)",
|
||||
"description": "超链接地址(type='link' 时必填);@文档 mention(mention_type 非 0)时也必填,传文档 URL(如搜索结果里的文档链接),否则卡片不可点。@人(mention_type=0)不需要传",
|
||||
"type": "string"
|
||||
},
|
||||
"mention_token": {
|
||||
@@ -462,8 +462,21 @@
|
||||
"type": "string"
|
||||
},
|
||||
"mention_type": {
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)",
|
||||
"type": "number"
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取:1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
|
||||
"type": "number",
|
||||
"enum": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
8,
|
||||
11,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
30,
|
||||
38
|
||||
]
|
||||
},
|
||||
"notify": {
|
||||
"description": "是否发送通知(仅 type='mention' 时可选,默认 true)",
|
||||
@@ -1730,11 +1743,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1787,11 +1801,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2769,11 +2779,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2826,11 +2837,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6249,6 +6256,744 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"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": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"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": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,97 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLResolvesToSheet covers the two-step wiki path: a /wiki/
|
||||
// URL is resolved via get_node to its spreadsheet obj_token, which then feeds
|
||||
// the tool invoke. The tool stub is keyed on the resolved obj_token, so the
|
||||
// test would fail if the node_token were used unresolved.
|
||||
func TestExecute_WikiURLResolvesToSheet(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, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","index":0}]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Fatalf("sheets len = %d, want 1; out=%s", len(sheets), out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
|
||||
// non-spreadsheet obj_type before any tool invoke.
|
||||
func TestExecute_WikiURLWrongObjType(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": "docx",
|
||||
"obj_token": "docABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
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
|
||||
// (missing obj_type/obj_token) as an internal/server error, not a user --url
|
||||
// validation error.
|
||||
func TestExecute_WikiURLIncompleteNode(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{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
if err == nil {
|
||||
t.Fatal("want error for incomplete get_node node data")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
t.Fatalf("incomplete-data error classified as validation (%v); want internal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
|
||||
// when only --sheet-name is given (and --source-index omitted) first
|
||||
// reads the workbook structure to derive sheet_id + source_index, then
|
||||
@@ -365,14 +456,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -382,8 +476,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -393,14 +487,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -421,8 +514,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
|
||||
@@ -80,3 +80,28 @@ func flagsFor(command string) []common.Flag {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flagAcceptsStdin reports whether the (command, flag) pair declares stdin as
|
||||
// an input source in flag-defs.json. Used to decide whether an "invalid JSON"
|
||||
// error should also steer the caller toward stdin. It runs on an error path,
|
||||
// so it returns false for an unknown command/flag rather than panicking the
|
||||
// way flagsFor does.
|
||||
func flagAcceptsStdin(command, name string) bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Name != name {
|
||||
continue
|
||||
}
|
||||
for _, in := range df.Input {
|
||||
if in == common.Stdin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -305,10 +305,9 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
@@ -320,7 +319,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -766,7 +765,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -793,6 +792,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -839,6 +848,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -895,13 +914,39 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{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"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
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: "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"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "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`): 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"},
|
||||
},
|
||||
},
|
||||
@@ -916,6 +961,14 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
|
||||
@@ -65,9 +65,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
if url == nil || url.Required {
|
||||
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
|
||||
}
|
||||
// hidden + int default
|
||||
// visible + int default
|
||||
cap := byName("+cells-get", "max-chars")
|
||||
if cap == nil || !cap.Hidden || cap.Default != "200000" {
|
||||
if cap == nil || cap.Hidden || cap.Default != "500000" {
|
||||
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
|
||||
}
|
||||
// input sources
|
||||
@@ -140,3 +140,24 @@ func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagAcceptsStdin verifies the stdin-capability probe that decides whether
|
||||
// an "invalid JSON" error should also steer the caller toward stdin: a composite
|
||||
// flag (cells) accepts stdin, a plain locator (spreadsheet-token) does not, and
|
||||
// an unknown command/flag returns false without panicking (it runs on an error
|
||||
// path, unlike flagsFor).
|
||||
func TestFlagAcceptsStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !flagAcceptsStdin("+cells-set", "cells") {
|
||||
t.Error("+cells-set --cells should accept stdin")
|
||||
}
|
||||
if flagAcceptsStdin("+cells-set", "spreadsheet-token") {
|
||||
t.Error("--spreadsheet-token should not accept stdin")
|
||||
}
|
||||
if flagAcceptsStdin("+nope", "cells") {
|
||||
t.Error("unknown command should be false (and must not panic)")
|
||||
}
|
||||
if flagAcceptsStdin("+cells-set", "nope") {
|
||||
t.Error("unknown flag should be false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
||||
var parseJSONFlagSkip = map[string]struct{}{
|
||||
"properties": {},
|
||||
"operations": {},
|
||||
"styles": {},
|
||||
}
|
||||
|
||||
// validateValueAgainstSchema is the (command, flag) → schema → check
|
||||
@@ -93,7 +94,17 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
|
||||
var schema schemaProperty
|
||||
json.Unmarshal(raw, &schema)
|
||||
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
|
||||
return sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error())
|
||||
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
||||
// --properties) are the highest-frequency usage-layer failure for
|
||||
// sheets, and agents often burn several retries guessing the shape.
|
||||
// Point them straight at --print-schema, which dumps the exact JSON
|
||||
// Schema for this (command, flag) pair. The hint is always actionable:
|
||||
// reaching this branch means entry[name] resolved a schema from the
|
||||
// embedded index, and --print-schema reads that same index, so the
|
||||
// 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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -587,3 +587,27 @@ func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
|
||||
t.Errorf("operations should be skipped; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateValueAgainstSchema_PrintSchemaHint pins the highest-value
|
||||
// recovery affordance for composite-JSON flags: when the shape is wrong, the
|
||||
// error must point the agent straight at --print-schema (with the right
|
||||
// command + flag) instead of leaving it to guess across retries. +cells-set
|
||||
// --cells expects a 2-D array; a bare string trips the top-level type check.
|
||||
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).
|
||||
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(msg, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
|
||||
t.Errorf("want --print-schema hint with command+flag; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
|
||||
"+range-sort": {},
|
||||
"+sparkline-create": {},
|
||||
"+sparkline-update": {},
|
||||
"+table-put": {},
|
||||
"+workbook-create": {},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ package sheets
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -48,46 +50,151 @@ func sheetsInputStatError(flag string, err error) error {
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
|
||||
// pair shared by every sheets canonical shortcut and returns the resolved
|
||||
// token. Network-free, safe to call from Validate and DryRun.
|
||||
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
|
||||
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
|
||||
// wiki node that must be resolved to its backing spreadsheet at Execute time.
|
||||
const (
|
||||
spreadsheetRefSheet = "sheet"
|
||||
spreadsheetRefWiki = "wiki"
|
||||
)
|
||||
|
||||
// spreadsheetRef is a parsed --url / --spreadsheet-token input. A wiki ref holds
|
||||
// the still-unresolved wiki node_token; resolveSpreadsheetTokenExec turns it
|
||||
// into the real spreadsheet token at Execute time.
|
||||
type spreadsheetRef struct {
|
||||
Kind string // spreadsheetRefSheet | spreadsheetRefWiki
|
||||
Token string
|
||||
}
|
||||
|
||||
// parseSpreadsheetRef applies the public --url / --spreadsheet-token XOR pair and
|
||||
// classifies the input. Network-free, safe to call from Validate and DryRun.
|
||||
//
|
||||
// Recognized --url shapes:
|
||||
// - https://.../sheets/<token> → {sheet, token}
|
||||
// - https://.../spreadsheets/<token> → {sheet, token}
|
||||
// - https://.../wiki/<node_token> → {wiki, node_token} (resolved at Execute)
|
||||
//
|
||||
// A raw --spreadsheet-token is always treated as a spreadsheet token; wiki nodes
|
||||
// only ever arrive as a /wiki/ URL.
|
||||
func parseSpreadsheetRef(runtime *common.RuntimeContext) (spreadsheetRef, error) {
|
||||
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
|
||||
return "", err
|
||||
return spreadsheetRef{}, err
|
||||
}
|
||||
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
|
||||
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
|
||||
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
|
||||
return spreadsheetRef{}, sheetsValidationCauseForFlag("spreadsheet-token", err)
|
||||
}
|
||||
return token, nil
|
||||
return spreadsheetRef{Kind: spreadsheetRefSheet, Token: token}, nil
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(runtime.Str("url"))
|
||||
token := extractSpreadsheetToken(url)
|
||||
if token == "" || token == url {
|
||||
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
|
||||
rawURL := strings.TrimSpace(runtime.Str("url"))
|
||||
token, kind, ok := spreadsheetURLToken(rawURL)
|
||||
if !ok {
|
||||
return spreadsheetRef{}, sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token> or a wiki URL like https://.../wiki/<token>")
|
||||
}
|
||||
if err := validate.RejectControlChars(token, "url"); err != nil {
|
||||
return "", sheetsValidationCauseForFlag("url", err)
|
||||
return spreadsheetRef{}, sheetsValidationCauseForFlag("url", err)
|
||||
}
|
||||
return token, nil
|
||||
return spreadsheetRef{Kind: kind, Token: token}, nil
|
||||
}
|
||||
|
||||
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
|
||||
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
|
||||
// prefix is present (callers must check token != originalInput).
|
||||
func extractSpreadsheetToken(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
|
||||
if idx := strings.Index(input, prefix); idx >= 0 {
|
||||
token := input[idx+len(prefix):]
|
||||
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
|
||||
token = token[:idx2]
|
||||
}
|
||||
return token
|
||||
// spreadsheetURLToken extracts the token and its kind from a Lark URL, matching
|
||||
// only on the URL *path* segment (parsed via net/url). A /wiki/ or /sheets/ that
|
||||
// appears only in the query or fragment (e.g. a redirect or anchor param) never
|
||||
// hijacks classification. Returns ok=false when no known prefix heads the path.
|
||||
func spreadsheetURLToken(rawURL string) (token, kind string, ok bool) {
|
||||
u, err := neturl.Parse(rawURL)
|
||||
if err != nil || u.Path == "" {
|
||||
return "", "", false
|
||||
}
|
||||
for _, m := range []struct {
|
||||
prefix string
|
||||
kind string
|
||||
}{
|
||||
{"/sheets/", spreadsheetRefSheet},
|
||||
{"/spreadsheets/", spreadsheetRefSheet},
|
||||
{"/wiki/", spreadsheetRefWiki},
|
||||
} {
|
||||
if seg, found := pathSegmentAfter(u.Path, m.prefix); found {
|
||||
return seg, m.kind, true
|
||||
}
|
||||
}
|
||||
return input
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// pathSegmentAfter returns the first path segment after prefix when path begins
|
||||
// with prefix, else ("", false).
|
||||
func pathSegmentAfter(path, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if i := strings.IndexByte(rest, '/'); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR pair
|
||||
// and returns the resolved token. Network-free, safe to call from Validate and
|
||||
// DryRun.
|
||||
//
|
||||
// A /wiki/ URL yields the still-unresolved wiki node_token: turning it into the
|
||||
// backing spreadsheet token needs a get_node call, which only Execute may make.
|
||||
// Validate/DryRun only need a non-empty, control-char-clean token, so the
|
||||
// node_token passes through unchanged here; Execute paths call
|
||||
// resolveSpreadsheetTokenExec instead.
|
||||
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, err := parseSpreadsheetRef(runtime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
|
||||
// resolveSpreadsheetTokenExec is the Execute-time counterpart of
|
||||
// resolveSpreadsheetToken: it additionally resolves a /wiki/ URL's node_token to
|
||||
// the backing spreadsheet token via wiki get_node, verifying obj_type=sheet.
|
||||
// Non-wiki inputs make no API call. Use this from every sheets Execute hook and
|
||||
// keep resolveSpreadsheetToken in Validate/DryRun so those stay network-free.
|
||||
func resolveSpreadsheetTokenExec(runtime *common.RuntimeContext) (string, error) {
|
||||
ref, err := parseSpreadsheetRef(runtime)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ref.Kind != spreadsheetRefWiki {
|
||||
return ref.Token, nil
|
||||
}
|
||||
return resolveWikiNodeToSpreadsheetToken(runtime, ref.Token)
|
||||
}
|
||||
|
||||
// resolveWikiNodeToSpreadsheetToken resolves a wiki node_token to the spreadsheet
|
||||
// obj_token it points at, erroring when the node is not a spreadsheet. The
|
||||
// wiki:node:read scope is only needed on this path, so it is enforced here rather
|
||||
// than declared unconditionally on every sheets shortcut.
|
||||
func resolveWikiNodeToSpreadsheetToken(runtime *common.RuntimeContext, nodeToken string) (string, error) {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": nodeToken}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := common.GetString(node, "obj_type")
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data for %q", nodeToken)
|
||||
}
|
||||
if objType != "sheet" {
|
||||
return "", sheetsValidationForFlag("url", "wiki URL resolves to obj_type=%q, but a spreadsheet (obj_type=sheet) is required", objType)
|
||||
}
|
||||
return objToken, nil
|
||||
}
|
||||
|
||||
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
|
||||
@@ -241,6 +348,16 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
// Composite payloads that embed formulas / quotes / commas are the
|
||||
// classic source of this error: inlined into the shell, the JSON gets
|
||||
// mangled (e.g. `\$` → "invalid character in string escape"). For any
|
||||
// flag that accepts stdin, steer the caller there — passing the payload
|
||||
// via `--<flag> - < file` sidesteps shell escaping entirely.
|
||||
if flagAcceptsStdin(runtime.Command(), name) {
|
||||
return nil, sheetsValidationForFlag(name,
|
||||
"--%s: invalid JSON: %v; if the payload contains formulas / quotes / commas, pass it via stdin (`--%s - < file`) so the shell doesn't mangle the JSON",
|
||||
name, err, name).WithCause(err)
|
||||
}
|
||||
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
|
||||
}
|
||||
// Schema-driven flag validation at the user-input boundary. Skips
|
||||
@@ -325,6 +442,72 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
|
||||
return style
|
||||
}
|
||||
|
||||
// cellStyleAliases maps shorthand cell_styles field names that models commonly
|
||||
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
|
||||
// names the backend expects. Only the unambiguous alignment shorthands are
|
||||
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
|
||||
// "bg_color", "text_align") are intentionally left out so a wrong guess still
|
||||
// surfaces as an error rather than being silently reinterpreted.
|
||||
var cellStyleAliases = []struct{ alias, canonical string }{
|
||||
{"horizontal_align", "horizontal_alignment"},
|
||||
{"halign", "horizontal_alignment"},
|
||||
{"vertical_align", "vertical_alignment"},
|
||||
{"valign", "vertical_alignment"},
|
||||
}
|
||||
|
||||
// normalizeCellStyleAliases renames known shorthand keys in a single
|
||||
// cell_styles map to their canonical equivalents, in place, so a model that
|
||||
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
|
||||
// applies the style instead of hitting an "unsupported field" error (--styles)
|
||||
// or having the field silently dropped by the backend (typed --cells). If both
|
||||
// the shorthand and its canonical key are present it returns a validation error
|
||||
// rather than picking one. path labels the map for the error message.
|
||||
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
|
||||
if len(style) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, a := range cellStyleAliases {
|
||||
v, ok := style[a.alias]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := style[a.canonical]; exists {
|
||||
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
|
||||
}
|
||||
style[a.canonical] = v
|
||||
delete(style, a.alias)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
|
||||
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
|
||||
// alignment shorthands are accepted on +cells-set the same as on --styles.
|
||||
// Structure is checked leniently to match the pass-through contract: any
|
||||
// element that isn't the expected shape is skipped, not rejected.
|
||||
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
|
||||
for r, rowRaw := range cells {
|
||||
row, ok := rowRaw.([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for c, cellRaw := range row {
|
||||
cell, ok := cellRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st, ok := cell["cell_styles"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
|
||||
// left/right with style sub-objects). Returns nil when the flag is empty.
|
||||
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
|
||||
|
||||
@@ -268,3 +268,53 @@ const (
|
||||
testSheetID = "shtSubA"
|
||||
testSheetID2 = "shtSubB"
|
||||
)
|
||||
|
||||
// TestParseSpreadsheetRef locks the network-free classification of
|
||||
// --url / --spreadsheet-token into a sheet token vs an (unresolved) wiki
|
||||
// node_token. The wiki node is resolved later, at Execute time only.
|
||||
func TestParseSpreadsheetRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
mk := func(url, tok string) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "sheets"}
|
||||
cmd.Flags().String("url", url, "")
|
||||
cmd.Flags().String("spreadsheet-token", tok, "")
|
||||
return common.TestNewRuntimeContext(cmd, testConfig(t))
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
tok string
|
||||
wantKind string
|
||||
wantToken string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "sheets url", url: "https://x.feishu.cn/sheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "spreadsheets url", url: "https://x.feishu.cn/spreadsheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "wiki url", url: "https://x.feishu.cn/wiki/wikDEF", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
|
||||
{name: "wiki url with query", url: "https://x.feishu.cn/wiki/wikDEF?sheet=xxxxxx", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
|
||||
{name: "raw token", tok: "shtRAW", wantKind: spreadsheetRefSheet, wantToken: "shtRAW"},
|
||||
{name: "sheets url with /wiki/ in query stays sheet", url: "https://x.feishu.cn/sheets/shtABC?from=/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "sheets url with /wiki/ in fragment stays sheet", url: "https://x.feishu.cn/sheets/shtABC#/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
|
||||
{name: "docx url unsupported", url: "https://x.feishu.cn/docx/docABC", wantErr: true},
|
||||
{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))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got ref=%+v", ref)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.Kind != tc.wantKind || ref.Token != tc.wantToken {
|
||||
t.Fatalf("ref = %+v, want {Kind:%s Token:%s}", ref, tc.wantKind, tc.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ var BatchUpdate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -180,7 +180,7 @@ var CellsBatchSetStyle = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -270,7 +270,7 @@ var CellsBatchClear = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -350,7 +350,7 @@ var DropdownUpdate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -396,7 +396,7 @@ var DropdownDelete = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -49,6 +50,20 @@ type objectCRUDSpec struct {
|
||||
// right nesting level.
|
||||
enhanceCreateInput func(rt flagView, input map[string]interface{})
|
||||
enhanceUpdateInput func(rt flagView, input map[string]interface{})
|
||||
// validateCreateInput, when set, runs after enhanceCreateInput to
|
||||
// enforce cross-flag / cross-field, create-only constraints JSON
|
||||
// Schema can't express. Two uses today:
|
||||
// - pivot rejects --target-position vs --range when both carry
|
||||
// non-default values — they map to the same wire field and
|
||||
// conflicting values are ambiguous (needs raw flags via rt).
|
||||
// - cond-format requires every properties.attrs entry to match the
|
||||
// sibling rule_type's shape (see validateCondFormatAttrs); a
|
||||
// colorScale rule fed cellIs-shaped attrs writes a color-less
|
||||
// segment that breaks the sheet on open (inspects input only).
|
||||
// It is the create-path twin of validateUpdateInput; the same scope
|
||||
// notes apply. Validators that only inspect the wire input can ignore
|
||||
// the rt argument.
|
||||
validateCreateInput func(rt flagView, input map[string]interface{}) error
|
||||
// validateUpdateInput, when set, runs after enhanceUpdateInput to
|
||||
// enforce *cross-field, update-only* constraints JSON Schema can't
|
||||
// express (e.g. sparkline requires properties.sparklines[i] to
|
||||
@@ -140,7 +155,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -190,6 +205,11 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
|
||||
if spec.enhanceCreateInput != nil {
|
||||
spec.enhanceCreateInput(runtime, input)
|
||||
}
|
||||
if spec.validateCreateInput != nil {
|
||||
if err := spec.validateCreateInput(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateInputAgainstSchema(runtime, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,7 +244,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -308,7 +328,7 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -381,9 +401,6 @@ var pivotSpec = objectCRUDSpec{
|
||||
},
|
||||
createWarn: pivotPlacementWarn,
|
||||
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
input["target_position"] = v
|
||||
}
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return
|
||||
@@ -391,10 +408,26 @@ var pivotSpec = objectCRUDSpec{
|
||||
if v := strings.TrimSpace(rt.Str("source")); v != "" {
|
||||
props["source"] = v
|
||||
}
|
||||
if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
// --target-position 与 --range 都映射到 properties.range;
|
||||
// --target-position 优先,未给(或为默认值 A1)时回落到 --range。
|
||||
// 互斥校验在 validateCreateInput 里做。
|
||||
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
|
||||
props["range"] = v
|
||||
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
|
||||
props["range"] = v
|
||||
}
|
||||
},
|
||||
// --target-position 与 --range 落到同一 wire 字段(properties.range),
|
||||
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
|
||||
// 的处理方式,CLI 端直接拒绝(优于静默丢弃其一)。
|
||||
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
|
||||
pos := strings.TrimSpace(rt.Str("target-position"))
|
||||
rng := strings.TrimSpace(rt.Str("range"))
|
||||
if pos != "" && pos != "A1" && rng != "" {
|
||||
return common.ValidationErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var PivotCreate = newObjectCreateShortcut(pivotSpec)
|
||||
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
|
||||
@@ -487,7 +520,118 @@ var condFormatSpec = objectCRUDSpec{
|
||||
idField: "conditional_format_id",
|
||||
enhanceCreateInput: condFormatEnhance,
|
||||
enhanceUpdateInput: condFormatEnhance,
|
||||
// validateCondFormatAttrs only inspects the wire input, so the create
|
||||
// hook ignores rt; the update hook (func(input)) calls it directly.
|
||||
validateCreateInput: func(_ flagView, input map[string]interface{}) error {
|
||||
return validateCondFormatAttrs(input)
|
||||
},
|
||||
validateUpdateInput: validateCondFormatAttrs,
|
||||
}
|
||||
|
||||
// condFormatAttrsRequired maps each conditional-format rule_type to the
|
||||
// keys every properties.attrs entry must carry for that rule. It mirrors
|
||||
// the per-rule attrs contract the tool's manage_conditional_format_object
|
||||
// converter reads (byted-sheet ai-tools manage-conditional-format-object.ts):
|
||||
// that converter maps each attrs entry *blindly by rule_type*, so a
|
||||
// colorScale rule fed cellIs-shaped attrs ({compare_type,value}) silently
|
||||
// yields a color-less color-scale segment — dirty data that crashes the
|
||||
// frontend on snapshot deserialization (the 5005 "can't open" report this
|
||||
// validator was added for).
|
||||
//
|
||||
// JSON Schema can't catch this: properties.attrs.items is a oneOf over all
|
||||
// nine shapes, and the validator accepts an entry as soon as *any* branch
|
||||
// matches — blind to the sibling rule_type. {compare_type,value} matches
|
||||
// the cellIs branch regardless of whether rule_type says colorScale.
|
||||
//
|
||||
// Rule types absent from the map (duplicateValues, uniqueValues,
|
||||
// containsBlanks, notContainsBlanks) carry no attrs, so nothing to check.
|
||||
// Counts (dataBar==2, colorScale 2–3, iconSet ordering) stay the tool's
|
||||
// job — it already rejects those with actionable messages; the gap this
|
||||
// closes is per-entry *shape*, which the tool does not check.
|
||||
var condFormatAttrsRequired = map[string][]string{
|
||||
"cellIs": {"compare_type", "value"},
|
||||
"containsText": {"compare_type", "text"},
|
||||
"timePeriod": {"operator", "time_period"},
|
||||
"dataBar": {"color", "value_type"},
|
||||
"colorScale": {"value_type", "color"},
|
||||
"rank": {"is_bottom", "value_type"},
|
||||
"aboveAverage": {"operator"},
|
||||
"expression": {"formula"},
|
||||
"iconSet": {"icon_type", "value_type", "operator"},
|
||||
}
|
||||
|
||||
// validateCondFormatAttrs enforces that every properties.attrs entry
|
||||
// matches the shape required by the sibling properties.rule_type. Shared
|
||||
// by create and update. On update, rule_type may be omitted (the caller is
|
||||
// editing style only and the existing rule's type governs the attrs shape,
|
||||
// which the CLI can't see); in that case validation is deferred to the
|
||||
// server. Missing/empty attrs is likewise left to the tool, which already
|
||||
// reports "attrs are required for rule_type: X" clearly.
|
||||
func validateCondFormatAttrs(input map[string]interface{}) error {
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if props == nil {
|
||||
return nil
|
||||
}
|
||||
ruleType, _ := props["rule_type"].(string)
|
||||
ruleType = strings.TrimSpace(ruleType)
|
||||
if ruleType == "" {
|
||||
return nil
|
||||
}
|
||||
required, ok := condFormatAttrsRequired[ruleType]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
attrs, ok := props["attrs"].([]interface{})
|
||||
if !ok {
|
||||
// Missing attrs, or a non-array shape the schema check already
|
||||
// flagged — nothing for this cross-field rule to add.
|
||||
return nil
|
||||
}
|
||||
for i, entryRaw := range attrs {
|
||||
entry, ok := entryRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue // schema validation owns per-entry type errors.
|
||||
}
|
||||
for _, key := range required {
|
||||
if v, has := entry[key]; !has || condAttrIsBlank(v) {
|
||||
return common.ValidationErrorf(
|
||||
"--properties: attrs[%d] is missing %q, which rule_type %q requires on every entry (expected keys %s; got %s). "+
|
||||
"A common cause is reusing another rule's attrs shape — e.g. cellIs-style {compare_type,value} under a colorScale rule, which writes a color-less segment that breaks the sheet on open.",
|
||||
i, key, ruleType, strings.Join(required, "+"), condAttrPresentKeys(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// condAttrIsBlank treats a present-but-empty string (after trimming) as
|
||||
// missing. The crash-causing case is an empty `color`, but an empty value
|
||||
// for any required key is never meaningful in these branches, so the rule
|
||||
// is uniform. Non-string values (numbers, booleans) count as present.
|
||||
func condAttrIsBlank(v interface{}) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return strings.TrimSpace(s) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// condAttrPresentKeys lists the keys actually present on an attrs entry,
|
||||
// sorted, for the "got ..." half of the error message.
|
||||
func condAttrPresentKeys(entry map[string]interface{}) string {
|
||||
if len(entry) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
keys := make([]string, 0, len(entry))
|
||||
for k := range entry {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return "{" + strings.Join(keys, ",") + "}"
|
||||
}
|
||||
|
||||
var CondFormatCreate = newObjectCreateShortcut(condFormatSpec)
|
||||
var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec)
|
||||
var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec)
|
||||
@@ -732,7 +876,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -882,7 +1026,7 @@ var FilterCreate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -957,7 +1101,7 @@ var FilterUpdate = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1025,7 +1169,7 @@ var FilterDelete = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -137,25 +139,24 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
// covered separately in the +pivot-create empty-selector / mutex
|
||||
// tests below.
|
||||
{
|
||||
name: "+pivot-create with placement / source / range flags",
|
||||
name: "+pivot-create with placement / source / target-position flags",
|
||||
sc: PivotCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--range", "F1",
|
||||
"--target-position", "B5",
|
||||
},
|
||||
toolName: "manage_pivot_table_object",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"target_position": "B5",
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"operation": "create",
|
||||
"properties": map[string]interface{}{
|
||||
"rows": []interface{}{map[string]interface{}{"field": "A"}},
|
||||
"source": "Sheet1!A1:F1000",
|
||||
"range": "F1",
|
||||
// --target-position 映射到 properties.range。
|
||||
"range": "B5",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -202,7 +203,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-id", "ruleA",
|
||||
"--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
|
||||
"--properties", `{"attrs":[{"compare_type":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
|
||||
"--rule-type", "cellIs",
|
||||
"--ranges", `["A1:A100"]`,
|
||||
},
|
||||
@@ -214,7 +215,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
|
||||
"conditional_format_id": "ruleA",
|
||||
"properties": map[string]interface{}{
|
||||
"rule_type": "cellIs",
|
||||
"attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}},
|
||||
"attrs": []interface{}{map[string]interface{}{"compare_type": "greaterThan", "value": "100"}},
|
||||
"style": map[string]interface{}{"back_color": "#FFD7D7"},
|
||||
"ranges": []interface{}{"A1:A100"},
|
||||
},
|
||||
@@ -507,6 +508,55 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
|
||||
// and --range cannot both be set" guardrail on +pivot-create. They map to
|
||||
// the same wire field (properties.range), so two non-default values are
|
||||
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
|
||||
// --target-sheet-name mutex). --target-position=A1 is the documented default
|
||||
// and is treated as "not set" — pairing it with --range still works.
|
||||
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("both non-default values rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "B5",
|
||||
"--range", "F1",
|
||||
})
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := parseDryRunBody(t, PivotCreate, []string{
|
||||
"--url", testURL,
|
||||
"--target-sheet-id", testSheetID,
|
||||
"--properties", `{"rows":[{"field":"A"}]}`,
|
||||
"--source", "Sheet1!A1:F1000",
|
||||
"--target-position", "A1",
|
||||
"--range", "F1",
|
||||
})
|
||||
input := decodeToolInput(t, body, "manage_pivot_table_object")
|
||||
props, _ := input["properties"].(map[string]interface{})
|
||||
if got, _ := props["range"].(string); got != "F1" {
|
||||
t.Errorf("properties.range = %q, want %q", got, "F1")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPivotCreate_SchemaValidates exercises the schema-driven
|
||||
// validator wired into objectCreateInput. The pivot create schema
|
||||
// doesn't constrain rows/columns/values to be present (the backend
|
||||
@@ -614,6 +664,182 @@ func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCondFormatAttrs_ShapeMatchesRuleType regresses the cross-field
|
||||
// guard that rejects attrs whose shape doesn't match the sibling
|
||||
// rule_type — the gap behind the "缺 color 的 colorScale 脏数据导致表格
|
||||
// 打不开" report: a colorScale rule fed cellIs-shaped attrs
|
||||
// ({compare_type,value}, no color) passed both the CLI's per-entry oneOf
|
||||
// schema check and the tool, writing a color-less segment that crashed
|
||||
// the frontend on open. The check covers create and update symmetrically.
|
||||
func TestCondFormatAttrs_ShapeMatchesRuleType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
sc common.Shortcut
|
||||
args []string
|
||||
wantErr bool
|
||||
wantMsg string // substring expected in the error, when wantErr
|
||||
}{
|
||||
{
|
||||
name: "colorScale fed cellIs-shaped attrs (missing color) is rejected",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"},{"compare_type":"lessThan","value":"100"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: true,
|
||||
wantMsg: "colorScale",
|
||||
},
|
||||
{
|
||||
name: "colorScale with empty color string is rejected",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":""},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: true,
|
||||
wantMsg: `"color"`,
|
||||
},
|
||||
{
|
||||
name: "well-formed colorScale attrs pass",
|
||||
sc: CondFormatCreate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":"#FFFFFF"},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "update path is guarded too (colorScale + cellIs attrs)",
|
||||
sc: CondFormatUpdate,
|
||||
args: []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA",
|
||||
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
|
||||
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"}]}`, "--dry-run",
|
||||
},
|
||||
wantErr: true,
|
||||
wantMsg: "colorScale",
|
||||
},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
|
||||
if tt.wantErr {
|
||||
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 {
|
||||
t.Fatalf("expected acceptance (dry-run); got err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCondFormatAttrsRequired_MatchesSchemaOneOf guards against drift
|
||||
// between the hand-maintained condFormatAttrsRequired table (the source
|
||||
// validateCondFormatAttrs enforces) and the embedded flag-schemas.json
|
||||
// attrs oneOf (the authoritative shape contract synced from the spec
|
||||
// repo). The cross-field validator only works if its per-rule_type
|
||||
// required keys mirror the schema branches; if a future schema sync adds
|
||||
// or drops a required key on any branch without updating the table, the
|
||||
// CLI would silently under- or over-validate. They share no compile-time
|
||||
// link, so this test is the only thing pinning them together.
|
||||
//
|
||||
// The schema oneOf branches are NOT labeled by rule_type (that's the whole
|
||||
// point — rule_type is a sibling field the per-entry oneOf can't see), so
|
||||
// we can't match branch→rule_type. We instead compare the *multiset* of
|
||||
// required-key sets: every branch's required array must appear as some
|
||||
// table entry's value and vice versa. This catches any added/dropped
|
||||
// required key (real drift); it tolerates only a relabeling between two
|
||||
// branches that happen to share an identical required set (dataBar and
|
||||
// colorScale both require {color,value_type}), which is harmless here.
|
||||
func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// multiset key: required keys sorted + joined, so order within a
|
||||
// branch's required array doesn't matter.
|
||||
keyOf := func(req []string) string {
|
||||
s := append([]string(nil), req...)
|
||||
sort.Strings(s)
|
||||
return strings.Join(s, "+")
|
||||
}
|
||||
|
||||
tableMS := map[string]int{}
|
||||
for _, req := range condFormatAttrsRequired {
|
||||
tableMS[keyOf(req)]++
|
||||
}
|
||||
|
||||
schemaMS := func(t *testing.T, command string) map[string]int {
|
||||
idx, err := loadFlagSchemas()
|
||||
if err != nil {
|
||||
t.Fatalf("loadFlagSchemas: %v", err)
|
||||
}
|
||||
raw, ok := idx.Flags[command]["properties"]
|
||||
if !ok {
|
||||
t.Fatalf("no embedded schema for %s --properties", command)
|
||||
}
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal %s properties schema: %v", command, err)
|
||||
}
|
||||
dig := func(m map[string]interface{}, key string) map[string]interface{} {
|
||||
next, _ := m[key].(map[string]interface{})
|
||||
if next == nil {
|
||||
t.Fatalf("%s: missing %q while navigating to attrs oneOf", command, key)
|
||||
}
|
||||
return next
|
||||
}
|
||||
attrs := dig(dig(schema, "properties"), "attrs")
|
||||
items := dig(attrs, "items")
|
||||
oneOf, ok := items["oneOf"].([]interface{})
|
||||
if !ok || len(oneOf) == 0 {
|
||||
t.Fatalf("%s: attrs.items.oneOf is missing or empty", command)
|
||||
}
|
||||
ms := map[string]int{}
|
||||
for i, branchRaw := range oneOf {
|
||||
branch, ok := branchRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s: oneOf[%d] is not an object", command, i)
|
||||
}
|
||||
reqRaw, _ := branch["required"].([]interface{})
|
||||
req := make([]string, 0, len(reqRaw))
|
||||
for _, r := range reqRaw {
|
||||
if s, ok := r.(string); ok {
|
||||
req = append(req, s)
|
||||
}
|
||||
}
|
||||
ms[keyOf(req)]++
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
for _, command := range []string{"+cond-format-create", "+cond-format-update"} {
|
||||
got := schemaMS(t, command)
|
||||
if len(got) != len(tableMS) {
|
||||
t.Errorf("%s: schema oneOf has %d distinct required-sets, table has %d", command, len(got), len(tableMS))
|
||||
}
|
||||
for k, n := range tableMS {
|
||||
if got[k] != n {
|
||||
t.Errorf("%s: required-set %q appears %d× in schema but %d× in condFormatAttrsRequired — table drifted from schema; re-sync the table", command, k, got[k], n)
|
||||
}
|
||||
}
|
||||
for k, n := range got {
|
||||
if tableMS[k] != n {
|
||||
t.Errorf("%s: schema branch with required-set %q (×%d) has no matching condFormatAttrsRequired entry — add it to the table", command, k, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: +float-image-update's image_name / position / size are cobra-required
|
||||
// (flag-defs.json), so the standalone path is gated by the flag layer — its
|
||||
// "required flag(s) … not set" wording is framework-owned and intentionally not
|
||||
|
||||
@@ -57,7 +57,7 @@ func newObjectListShortcut(spec objectListSpec) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ var CellsClear = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -163,7 +163,7 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short
|
||||
return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,7 +239,7 @@ var RowsResize = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -279,7 +279,7 @@ var ColsResize = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -451,7 +451,7 @@ var RangeFill = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -490,7 +490,7 @@ var RangeSort = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -59,7 +57,7 @@ var CellsGet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", cellsGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -152,7 +150,7 @@ var CsvGet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_range_as_csv", csvGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,12 +162,7 @@ var CsvGet = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case runtime.Bool("rows-json"):
|
||||
// --rows-json reshapes the CSV response into structured rows
|
||||
// ({row_number, values:{col→cell}}); see assembleRowsJSON.
|
||||
out = assembleRowsJSON(out, strings.TrimSpace(runtime.Str("range")))
|
||||
case !runtime.Bool("include-row-prefix"):
|
||||
if !runtime.Bool("include-row-prefix") {
|
||||
out = stripRowPrefixFromCsvOutput(out)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
@@ -219,141 +212,6 @@ func stripRowPrefixFromCsvOutput(out interface{}) interface{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// rowPrefixRe matches the leading "[row=N] " (or "[row=N],") annotation that
|
||||
// the tool prepends to the first physical line of each logical CSV record.
|
||||
var rowPrefixRe = regexp.MustCompile(`^\[row=(\d+)\][ ,]?`)
|
||||
|
||||
// assembleRowsJSON reshapes the tool's annotated_csv string into structured
|
||||
// rows so callers never have to regex-parse "[row=N]" or RFC-4180 CSV by hand:
|
||||
//
|
||||
// {
|
||||
// "range": "A1:K3380",
|
||||
// "current_region": "...", // passthrough, if the tool returned it
|
||||
// "rows": [{"row_number":1,"values":{"A":"姓名", ..., "K":"时间差_分钟"}},
|
||||
// {"row_number":2,"values":{"A":"张三", ..., "K":"8.5"}}, ...]
|
||||
// }
|
||||
//
|
||||
// Every logical row is emitted, including the first — no row is assumed to be a
|
||||
// header, since sheet data is not always tabular. Each cell is keyed by its
|
||||
// column letter (from the tool's col_indices when present, else derived from the
|
||||
// requested range's start column). On any parsing trouble it returns the
|
||||
// original output unchanged.
|
||||
func assembleRowsJSON(out interface{}, requestedRange string) interface{} {
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
csvStr, ok := m["annotated_csv"].(string)
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
// Group physical lines into logical records by [row=N] boundaries; lines
|
||||
// without a prefix are embedded-newline continuations of the current record.
|
||||
type logicalRow struct {
|
||||
num int
|
||||
text string
|
||||
}
|
||||
var groups []logicalRow
|
||||
for _, line := range strings.Split(csvStr, "\n") {
|
||||
if mm := rowPrefixRe.FindStringSubmatch(line); mm != nil {
|
||||
n, _ := strconv.Atoi(mm[1])
|
||||
groups = append(groups, logicalRow{num: n, text: line[len(mm[0]):]})
|
||||
} else if len(groups) > 0 {
|
||||
groups[len(groups)-1].text += "\n" + line
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse every logical row; widest row sets the column count. No row is
|
||||
// singled out as a header — that would assume the data is tabular, which it
|
||||
// often is not. The model reads row 1 like any other row and decides for
|
||||
// itself whether it is a header.
|
||||
parsed := make([][]string, len(groups))
|
||||
maxCols := 0
|
||||
for i, g := range groups {
|
||||
parsed[i] = parseCSVRecord(g.text)
|
||||
if len(parsed[i]) > maxCols {
|
||||
maxCols = len(parsed[i])
|
||||
}
|
||||
}
|
||||
if maxCols == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// Column letters key each cell. Prefer the tool's col_indices (authoritative,
|
||||
// length == col_count); otherwise derive from the requested range's start col.
|
||||
letters := coerceStringSlice(m["col_indices"])
|
||||
if len(letters) < maxCols {
|
||||
start := csvStartColIndex(requestedRange)
|
||||
letters = make([]string, maxCols)
|
||||
for j := 0; j < maxCols; j++ {
|
||||
letters[j] = csvColLetter(start + j)
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]map[string]interface{}, 0, len(groups))
|
||||
for i := range groups {
|
||||
fields := parsed[i]
|
||||
values := make(map[string]interface{}, len(letters))
|
||||
for j := range letters {
|
||||
v := ""
|
||||
if j < len(fields) {
|
||||
v = fields[j]
|
||||
}
|
||||
values[letters[j]] = v
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{
|
||||
"row_number": groups[i].num,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]interface{}{}
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
result["range"] = requestedRange
|
||||
result["rows"] = rows
|
||||
|
||||
// Surface the backend's "数据没读全" signal structurally instead of leaving it
|
||||
// buried in warning_message prose. The tool flags it when current_region (the
|
||||
// true data extent) reaches past actual_range (what was actually read) — the
|
||||
// single most important anti-under-read hint. Mirror that same comparison
|
||||
// (regionEndRow > actualEndRow) from the already-passthrough A1 ranges so the
|
||||
// model gets the real data range as a first-class field, never having to
|
||||
// parse it out of prose.
|
||||
if cr, _ := m["current_region"].(string); cr != "" {
|
||||
ar, _ := m["actual_range"].(string)
|
||||
regionEnd := a1EndRow(cr)
|
||||
readEnd := a1EndRow(ar)
|
||||
if regionEnd > 0 && readEnd > 0 && regionEnd > readEnd {
|
||||
result["data_not_fully_read"] = map[string]interface{}{
|
||||
"read_through_row": readEnd,
|
||||
"data_extends_through_row": regionEnd,
|
||||
"unread_rows": regionEnd - readEnd,
|
||||
"reread_range": cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the fields whose information rows-json fully carries elsewhere:
|
||||
// - annotated_csv / row_indices / col_indices → reconstructed into
|
||||
// columns + rows (with integer row_number), losslessly.
|
||||
// - warning_message → its two halves are both handled: the static
|
||||
// "[row=N] / col_indices[j]" parse nag is moot once those fields exist,
|
||||
// and the dynamic "数据没读全" half is now the structured
|
||||
// data_not_fully_read field above. (Confirmed against the backend's
|
||||
// get-range-as-csv.ts — warning_message has no other content.)
|
||||
delete(result, "annotated_csv")
|
||||
delete(result, "row_indices")
|
||||
delete(result, "col_indices")
|
||||
delete(result, "warning_message")
|
||||
return result
|
||||
}
|
||||
|
||||
// a1EndRow extracts the ending row number from an A1 range, e.g. "A1:N51" → 51,
|
||||
// "Sheet1!B2:D9" → 9, "C5" → 5. Returns 0 when no row number is present.
|
||||
func a1EndRow(rng string) int {
|
||||
@@ -377,89 +235,6 @@ func a1EndRow(rng string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseCSVRecord parses a single logical CSV record (which may span multiple
|
||||
// physical lines via quoted embedded newlines) into its fields. An empty record
|
||||
// yields no fields; a malformed record falls back to a naive comma split so a
|
||||
// stray quote never drops a whole row.
|
||||
func parseCSVRecord(text string) []string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
r := csv.NewReader(strings.NewReader(text))
|
||||
r.FieldsPerRecord = -1
|
||||
fields, err := r.Read()
|
||||
if err != nil {
|
||||
return strings.Split(text, ",")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// coerceStringSlice returns v as []string when it is a homogeneous []interface{}
|
||||
// of strings (the shape of the tool's col_indices), else nil.
|
||||
func coerceStringSlice(v interface{}) []string {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// csvStartColIndex returns the 0-based column index of a range's start column,
|
||||
// e.g. "A1:K3380" → 0, "C5:F9" → 2, "Sheet1!D2" → 3. Unparseable input → 0.
|
||||
func csvStartColIndex(rng string) int {
|
||||
rng = strings.TrimSpace(rng)
|
||||
if i := strings.LastIndex(rng, "!"); i >= 0 {
|
||||
rng = rng[i+1:]
|
||||
}
|
||||
var letters strings.Builder
|
||||
for _, c := range rng {
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letters.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if letters.Len() == 0 {
|
||||
return 0
|
||||
}
|
||||
return csvColToIndex(letters.String())
|
||||
}
|
||||
|
||||
// csvColToIndex converts a column letter to its 0-based index ("A"→0, "K"→10,
|
||||
// "AA"→26). Non-letter input → -1.
|
||||
func csvColToIndex(s string) int {
|
||||
n := 0
|
||||
for _, c := range strings.ToUpper(s) {
|
||||
if c < 'A' || c > 'Z' {
|
||||
break
|
||||
}
|
||||
n = n*26 + int(c-'A'+1)
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
// csvColLetter converts a 0-based column index back to its letter (0→"A",
|
||||
// 25→"Z", 26→"AA"). Negative input → "".
|
||||
func csvColLetter(idx int) string {
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
var b []byte
|
||||
for idx >= 0 {
|
||||
b = append([]byte{byte('A' + idx%26)}, b...)
|
||||
idx = idx/26 - 1
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DropdownGet wraps get_cell_ranges scoped to data_validation: read the
|
||||
// dropdown configuration on a range. Aligned with its sibling +cells-get
|
||||
// — sheet selection is via --sheet-id / --sheet-name (XOR), and --range
|
||||
@@ -494,7 +269,7 @@ var DropdownGet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_cell_ranges", dropdownGetInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -63,20 +63,6 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
|
||||
"value_render_option": "formatted_value",
|
||||
},
|
||||
},
|
||||
{
|
||||
// --rows-json is post-processing on +csv-get's response; it must
|
||||
// NOT leak into the get_range_as_csv input.
|
||||
name: "+csv-get --rows-json builds the same input (flag is post-process)",
|
||||
sc: CsvGet,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:C10", "--rows-json"},
|
||||
toolName: "get_range_as_csv",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"sheet_id": testSheetID,
|
||||
"range": "A1:C10",
|
||||
"max_rows": float64(unboundedReadLimit),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -179,113 +165,3 @@ func TestCsvGet_StripRowPrefix(t *testing.T) {
|
||||
t.Errorf("other field corrupted: %v", out["other"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON covers the --rows-json reshaping: every logical row
|
||||
// emitted (no header singled out), integer row_number, column-letter keyed
|
||||
// values, embedded newlines inside quoted fields, and current_region passthrough.
|
||||
func TestAssembleRowsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 姓名,备注,时间差_分钟\n[row=2] 张三,\"line1\nline2\",8.5\n[row=3] 李四,ok,3",
|
||||
"current_region": "A1:C3",
|
||||
"col_indices": []interface{}{"A", "B", "C"},
|
||||
"row_indices": []interface{}{1, 2, 3},
|
||||
"warning_message": "①定位行号…②定位列字母…",
|
||||
}
|
||||
out, ok := assembleRowsJSON(in, "A1:C3").(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("assembleRowsJSON did not return a map")
|
||||
}
|
||||
|
||||
// Fields whose info rows-json carries elsewhere are dropped (annotated_csv /
|
||||
// indices → rows; warning_message → moot static nag + structured
|
||||
// data_not_fully_read). Unrelated metadata like current_region is preserved.
|
||||
if _, exists := out["annotated_csv"]; exists {
|
||||
t.Errorf("annotated_csv should be dropped")
|
||||
}
|
||||
if _, exists := out["col_indices"]; exists {
|
||||
t.Errorf("col_indices should be dropped")
|
||||
}
|
||||
if _, exists := out["warning_message"]; exists {
|
||||
t.Errorf("warning_message should be dropped in rows-json mode")
|
||||
}
|
||||
if _, exists := out["columns"]; exists {
|
||||
t.Errorf("columns field should not exist (no header assumption)")
|
||||
}
|
||||
if out["current_region"] != "A1:C3" {
|
||||
t.Errorf("current_region passthrough lost: %v", out["current_region"])
|
||||
}
|
||||
|
||||
rows, _ := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("want all 3 rows (incl. row 1), got %d: %+v", len(rows), rows)
|
||||
}
|
||||
// Row 1 is emitted as a normal row, not consumed as a header.
|
||||
if rows[0]["row_number"].(int) != 1 {
|
||||
t.Errorf("first row_number = %v, want 1", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["A"] != "姓名" || v["C"] != "时间差_分钟" {
|
||||
t.Errorf("row 1 values wrong: %+v", v)
|
||||
}
|
||||
// Row 2 keeps its embedded newline inside a single cell.
|
||||
v1 := rows[1]["values"].(map[string]interface{})
|
||||
if rows[1]["row_number"].(int) != 2 || v1["A"] != "张三" || v1["B"] != "line1\nline2" || v1["C"] != "8.5" {
|
||||
t.Errorf("row 2 wrong (embedded newline?): %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DerivedLetters verifies cell letters are derived from the
|
||||
// range start when the tool omits col_indices (e.g. a C-anchored read).
|
||||
func TestAssembleRowsJSON_DerivedLetters(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=5] h1,h2\n[row=6] a,b",
|
||||
}
|
||||
out := assembleRowsJSON(in, "C5:D6").(map[string]interface{})
|
||||
rows := out["rows"].([]map[string]interface{})
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", len(rows))
|
||||
}
|
||||
if rows[0]["row_number"].(int) != 5 {
|
||||
t.Errorf("first row_number = %v, want 5", rows[0]["row_number"])
|
||||
}
|
||||
if v := rows[0]["values"].(map[string]interface{}); v["C"] != "h1" || v["D"] != "h2" {
|
||||
t.Errorf("derived-letter values wrong: %+v", v)
|
||||
}
|
||||
if v := rows[1]["values"].(map[string]interface{}); v["C"] != "a" || v["D"] != "b" {
|
||||
t.Errorf("row 6 values wrong: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssembleRowsJSON_DataNotFullyRead verifies the structured under-read hint:
|
||||
// when current_region extends past actual_range, rows-json surfaces the true data
|
||||
// range as a first-class field (mirroring the backend's prose warning).
|
||||
func TestAssembleRowsJSON_DataNotFullyRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Read only A1:D2, but the data region reaches D4 → 2 rows unread.
|
||||
in := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D4",
|
||||
}
|
||||
out := assembleRowsJSON(in, "A1:D2").(map[string]interface{})
|
||||
hint, ok := out["data_not_fully_read"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data_not_fully_read missing; out=%+v", out)
|
||||
}
|
||||
if hint["read_through_row"] != 2 || hint["data_extends_through_row"] != 4 ||
|
||||
hint["unread_rows"] != 2 || hint["reread_range"] != "A1:D4" {
|
||||
t.Errorf("data_not_fully_read wrong: %+v", hint)
|
||||
}
|
||||
|
||||
// Fully-read case: no hint emitted.
|
||||
in2 := map[string]interface{}{
|
||||
"annotated_csv": "[row=1] 序号,姓名\n[row=2] 101,张三",
|
||||
"actual_range": "A1:D2",
|
||||
"current_region": "A1:D2",
|
||||
}
|
||||
out2 := assembleRowsJSON(in2, "A1:D2").(map[string]interface{})
|
||||
if _, exists := out2["data_not_fully_read"]; exists {
|
||||
t.Errorf("data_not_fully_read should be absent when fully read")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ var CellsSearch = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -122,7 +122,7 @@ var CellsReplace = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "replace_data", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ var SheetInfo = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindRead, "get_sheet_structure", sheetInfoInput(runtime, token, sheetID, sheetName))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -136,7 +136,7 @@ var DimInsert = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -211,7 +211,7 @@ var DimDelete = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -300,7 +300,7 @@ var DimFreeze = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -395,7 +395,7 @@ func newDimRangeOpShortcut(command, desc, op, risk string) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func newDimGroupShortcut(command, desc, op string) common.Shortcut {
|
||||
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,7 +593,7 @@ var DimMove = common.Shortcut{
|
||||
Set("spreadsheet_token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
199
shortcuts/sheets/lark_sheet_style_alias_test.go
Normal file
199
shortcuts/sheets/lark_sheet_style_alias_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNormalizeCellStyleAliases pins the shorthand → canonical renaming for a
|
||||
// single cell_styles map: the alignment shorthands models commonly hallucinate
|
||||
// are rewritten in place, values are preserved, and a shorthand colliding with
|
||||
// its canonical key is a hard error rather than a silent pick.
|
||||
func TestNormalizeCellStyleAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("renames *_align shorthands, keeps values and other fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
style := map[string]interface{}{
|
||||
"horizontal_align": "center",
|
||||
"vertical_align": "middle",
|
||||
"font_weight": "bold",
|
||||
}
|
||||
if err := normalizeCellStyleAliases(style, "x"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if style["horizontal_alignment"] != "center" || style["vertical_alignment"] != "middle" {
|
||||
t.Errorf("alignment not renamed: %#v", style)
|
||||
}
|
||||
if _, ok := style["horizontal_align"]; ok {
|
||||
t.Errorf("shorthand horizontal_align should be removed: %#v", style)
|
||||
}
|
||||
if _, ok := style["vertical_align"]; ok {
|
||||
t.Errorf("shorthand vertical_align should be removed: %#v", style)
|
||||
}
|
||||
if style["font_weight"] != "bold" {
|
||||
t.Errorf("unrelated field font_weight dropped: %#v", style)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("renames halign/valign shorthands", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
style := map[string]interface{}{"halign": "left", "valign": "top"}
|
||||
if err := normalizeCellStyleAliases(style, "x"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if style["horizontal_alignment"] != "left" || style["vertical_alignment"] != "top" {
|
||||
t.Errorf("halign/valign not renamed: %#v", style)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("shorthand colliding with canonical is an error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
style := map[string]interface{}{
|
||||
"horizontal_align": "center",
|
||||
"horizontal_alignment": "left",
|
||||
}
|
||||
err := normalizeCellStyleAliases(style, "cell_styles[0]")
|
||||
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) {
|
||||
t.Parallel()
|
||||
style := map[string]interface{}{"font_weight": "bold", "horizontal_alignment": "center"}
|
||||
if err := normalizeCellStyleAliases(style, "x"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(style) != 2 || style["font_weight"] != "bold" || style["horizontal_alignment"] != "center" {
|
||||
t.Errorf("map should be unchanged: %#v", style)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty map is a no-op", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := normalizeCellStyleAliases(map[string]interface{}{}, "x"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalizeTypedCellsStyleAliases pins the 2D --cells walk: every cell's
|
||||
// inline cell_styles is normalized, malformed shapes are skipped (matching the
|
||||
// pass-through contract) rather than rejected, and a conflict propagates.
|
||||
func TestNormalizeTypedCellsStyleAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("normalizes inline cell_styles across the grid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := []interface{}{
|
||||
[]interface{}{
|
||||
map[string]interface{}{
|
||||
"value": "x",
|
||||
"cell_styles": map[string]interface{}{"horizontal_align": "center"},
|
||||
},
|
||||
map[string]interface{}{"value": "y"}, // no cell_styles → untouched
|
||||
},
|
||||
}
|
||||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
row := cells[0].([]interface{})
|
||||
st := row[0].(map[string]interface{})["cell_styles"].(map[string]interface{})
|
||||
if st["horizontal_alignment"] != "center" {
|
||||
t.Errorf("cell_styles not normalized: %#v", st)
|
||||
}
|
||||
if _, ok := st["horizontal_align"]; ok {
|
||||
t.Errorf("shorthand should be removed: %#v", st)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed shapes are skipped, not rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := []interface{}{
|
||||
"not-a-row",
|
||||
[]interface{}{
|
||||
"not-a-cell",
|
||||
map[string]interface{}{"cell_styles": "not-a-map"},
|
||||
},
|
||||
}
|
||||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||||
t.Fatalf("lenient walk should not error on odd shapes: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("conflict inside a cell propagates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := []interface{}{
|
||||
[]interface{}{
|
||||
map[string]interface{}{
|
||||
"cell_styles": map[string]interface{}{
|
||||
"valign": "top",
|
||||
"vertical_alignment": "middle",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := normalizeTypedCellsStyleAliases(cells, "--cells")
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellsSet_StyleAliasesNormalized is the end-to-end guard for +cells-set:
|
||||
// a typed --cells payload using alignment shorthands reaches set_cell_range
|
||||
// with canonical field names so the backend doesn't silently drop them.
|
||||
func TestCellsSet_StyleAliasesNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := `[[{"value":"Header","cell_styles":{"horizontal_align":"center","vertical_align":"middle","font_weight":"bold"}}]]`
|
||||
body := parseDryRunBody(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells,
|
||||
})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
s := string(raw)
|
||||
if !strings.Contains(s, `"horizontal_alignment":"center"`) || !strings.Contains(s, `"vertical_alignment":"middle"`) {
|
||||
t.Errorf("alignment shorthands not normalized in cells: %s", s)
|
||||
}
|
||||
if strings.Contains(s, `"horizontal_align":`) || strings.Contains(s, `"vertical_align":`) {
|
||||
t.Errorf("shorthand keys leaked through to backend payload: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookCreate_StyleAliasesNormalized is the end-to-end guard for
|
||||
// +workbook-create --styles: alignment shorthands in a cell_styles op are
|
||||
// accepted (no "unsupported style field" error) and emitted as canonical
|
||||
// field names merged into the fill cells.
|
||||
func TestWorkbookCreate_StyleAliasesNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_align":"center","vertical_align":"middle"}]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
s := string(raw)
|
||||
if c := strings.Count(s, `"horizontal_alignment":"center"`); c != 4 {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", c, s)
|
||||
}
|
||||
if strings.Contains(s, `"horizontal_align":`) || strings.Contains(s, `"vertical_align":`) {
|
||||
t.Errorf("shorthand keys leaked through after normalization: %s", s)
|
||||
}
|
||||
}
|
||||
1615
shortcuts/sheets/lark_sheet_table_io.go
Normal file
1615
shortcuts/sheets/lark_sheet_table_io.go
Normal file
File diff suppressed because it is too large
Load Diff
1497
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
1497
shortcuts/sheets/lark_sheet_table_io_test.go
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
72
shortcuts/sheets/lark_sheet_workbook_export_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
|
||||
// --output-path, +workbook-export delegates to the shared drive export core
|
||||
// with OutputDir="" so it creates + polls the export task and returns the ready
|
||||
// file token without writing a local file (downloaded=false).
|
||||
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_export"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"job_status": float64(0),
|
||||
"file_token": "ftk_xlsx",
|
||||
"file_name": "report.xlsx",
|
||||
"file_size": float64(2048),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
|
||||
}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("export-only execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if env.Data["ready"] != true {
|
||||
t.Errorf("ready = %v, want true", env.Data["ready"])
|
||||
}
|
||||
if env.Data["downloaded"] != false {
|
||||
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
|
||||
}
|
||||
if env.Data["file_token"] != "ftk_xlsx" {
|
||||
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
|
||||
}
|
||||
if env.Data["doc_type"] != "sheet" {
|
||||
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
|
||||
}
|
||||
}
|
||||
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
135
shortcuts/sheets/lark_sheet_workbook_import_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// chdirTemp switches into a fresh temp dir for the duration of the test and
|
||||
// restores the original cwd afterwards. +workbook-import is the first sheets
|
||||
// shortcut that stat()s a real local file, so these tests need a working dir.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(t.TempDir()); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
|
||||
// shared drive import core and hard-codes the import target type to "sheet".
|
||||
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
|
||||
|
||||
var createBody map[string]interface{}
|
||||
for _, c := range calls {
|
||||
cm, _ := c.(map[string]interface{})
|
||||
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
|
||||
createBody, _ = cm["body"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
if createBody == nil {
|
||||
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
|
||||
}
|
||||
if createBody["type"] != "sheet" {
|
||||
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
|
||||
}
|
||||
if createBody["file_extension"] != "xlsx" {
|
||||
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
|
||||
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
|
||||
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
|
||||
// front and the error surfaces through the normal envelope/err path.
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
|
||||
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
|
||||
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
|
||||
// flow against stubs and asserts the resulting URL is a /sheets/ link.
|
||||
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
|
||||
stubs := []*httpmock.Stub{
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": "tk_sheet"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": map[string]interface{}{
|
||||
"token": "shtcn_imported",
|
||||
"type": "sheet",
|
||||
"job_status": float64(0),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
|
||||
if err != nil {
|
||||
t.Fatalf("import execute failed: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
idx := strings.Index(out, "{")
|
||||
if idx < 0 {
|
||||
t.Fatalf("execute output has no JSON envelope:\n%s", out)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
|
||||
}
|
||||
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
|
||||
t.Errorf("imported url = %q, want a /sheets/ link", url)
|
||||
}
|
||||
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
|
||||
t.Errorf("token = %q, want shtcn_imported", tok)
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,10 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -145,6 +141,28 @@ func TestWorkbookShortcuts_DryRun(t *testing.T) {
|
||||
"tab_color": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "show_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--url", testURL, "--sheet-id", testSheetID},
|
||||
toolName: "modify_workbook_structure",
|
||||
wantInput: map[string]interface{}{
|
||||
"excel_id": testToken,
|
||||
"operation": "hide_gridline",
|
||||
"sheet_id": testSheetID,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -288,7 +306,7 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{"--title", "MySheet"})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (no headers/data)", len(calls))
|
||||
t.Fatalf("api calls = %d, want 1 (no values)", len(calls))
|
||||
}
|
||||
c := calls[0].(map[string]interface{})
|
||||
if c["url"] != "/open-apis/sheets/v3/spreadsheets" {
|
||||
@@ -300,12 +318,11 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with headers and data → 2-step plan", func(t *testing.T) {
|
||||
t.Run("with values → 2-step plan", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95],["bob",88]]`,
|
||||
"--values", `[["Name","Score"],["alice",95],["bob",88]]`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
@@ -317,7 +334,88 @@ func TestWorkbookCreate_DryRun(t *testing.T) {
|
||||
body, _ := fill["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
if input["range"] != "A1:B3" {
|
||||
t.Errorf("fill range = %v, want A1:B3 (1 header + 2 data rows × 2 cols)", input["range"])
|
||||
t.Errorf("fill range = %v, want A1:B3 (3 rows × 2 cols)", input["range"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with styles merges into set_cell_range cells", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","font_weight":"bold","background_color":"#f5f5f5"},{"range":"B1","number_format":"0","border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},{"range":"B2","font_color":"#0f7b0f"}]}]}`,
|
||||
})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
|
||||
}
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
if len(cells) != 2 {
|
||||
t.Fatalf("cells rows = %#v, want 2", input["cells"])
|
||||
}
|
||||
headerRow, _ := cells[0].([]interface{})
|
||||
firstHeader, _ := headerRow[0].(map[string]interface{})
|
||||
firstStyle, _ := firstHeader["cell_styles"].(map[string]interface{})
|
||||
if firstStyle["font_weight"] != "bold" || firstStyle["background_color"] != "#f5f5f5" {
|
||||
t.Errorf("first header style = %#v, want bold + background", firstStyle)
|
||||
}
|
||||
secondHeader, _ := headerRow[1].(map[string]interface{})
|
||||
if secondHeader["border_styles"] == nil {
|
||||
t.Errorf("second header missing border_styles: %#v", secondHeader)
|
||||
}
|
||||
secondStyle, _ := secondHeader["cell_styles"].(map[string]interface{})
|
||||
if secondStyle["number_format"] != "0" {
|
||||
t.Errorf("second header number_format = %#v, want 0", secondStyle)
|
||||
}
|
||||
dataRow, _ := cells[1].([]interface{})
|
||||
firstData, _ := dataRow[0].(map[string]interface{})
|
||||
if _, ok := firstData["cell_styles"]; ok {
|
||||
t.Errorf("null style should leave first data cell unstyled: %#v", firstData)
|
||||
}
|
||||
secondData, _ := dataRow[1].(map[string]interface{})
|
||||
secondDataStyle, _ := secondData["cell_styles"].(map[string]interface{})
|
||||
if secondDataStyle["font_color"] != "#0f7b0f" {
|
||||
t.Errorf("second data style = %#v, want font color", secondDataStyle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cell style range can cover the whole initial range", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_alignment":"center"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
raw, _ := json.Marshal(input["cells"])
|
||||
if got := strings.Count(string(raw), "horizontal_alignment"); got != 4 {
|
||||
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", got, raw)
|
||||
}
|
||||
})
|
||||
t.Run("overlapping cell_styles deep-merge fields, no cross-cell pollution", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookCreate, []string{
|
||||
"--title", "X",
|
||||
"--values", `[["a","b"]]`,
|
||||
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold"},{"range":"B1","font_color":"#ff0000"}]}]}`,
|
||||
})
|
||||
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
|
||||
input := decodeToolInput(t, body, "set_cell_range")
|
||||
cells, _ := input["cells"].([]interface{})
|
||||
row0, _ := cells[0].([]interface{})
|
||||
// B1 hit by both ops → must keep BOTH font_weight (op1) and font_color (op2).
|
||||
b1, _ := row0[1].(map[string]interface{})
|
||||
b1s, _ := b1["cell_styles"].(map[string]interface{})
|
||||
if b1s["font_weight"] != "bold" || b1s["font_color"] != "#ff0000" {
|
||||
t.Errorf("B1 should deep-merge both ops, got %#v", b1s)
|
||||
}
|
||||
// A1 hit only by op1 → must NOT be polluted by op2's font_color (shared submap).
|
||||
a1, _ := row0[0].(map[string]interface{})
|
||||
a1s, _ := a1["cell_styles"].(map[string]interface{})
|
||||
if a1s["font_color"] != nil {
|
||||
t.Errorf("A1 must not be polluted by op2, got %#v", a1s)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -330,8 +428,18 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"headers not array", []string{"--title", "X", "--headers", `"abc"`}, "must be a JSON array"},
|
||||
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
|
||||
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
|
||||
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
|
||||
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
|
||||
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
|
||||
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
|
||||
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
|
||||
{"row size needs row range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","row_sizes":[{"range":"A1","type":"pixel","size":20}]}]}`}, "must use row numbers"},
|
||||
{"col size needs pixel size", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","col_sizes":[{"range":"A:A","type":"pixel"}]}]}`}, "requires size"},
|
||||
{"border bad style enum", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"NONSENSE"}}}]}]}`}, `style "NONSENSE" is invalid`},
|
||||
{"border invalid side", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"diagonal":{"style":"solid"}}}]}]}`}, "not a valid side"},
|
||||
{"border bad weight", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","border_styles":{"top":{"weight":"xxl"}}}]}]}`}, `weight "xxl" is invalid`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -344,21 +452,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
|
||||
// --output-path. The order should be: POST → GET (poll) → optional GET
|
||||
// (download).
|
||||
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
|
||||
// shared drive export core: a single create-task POST (poll + download are
|
||||
// described inline rather than as separate api entries).
|
||||
func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
|
||||
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
|
||||
}
|
||||
create := calls[0].(map[string]interface{})
|
||||
if create["url"] != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Errorf("first url = %v", create["url"])
|
||||
t.Errorf("url = %v", create["url"])
|
||||
}
|
||||
body, _ := create["body"].(map[string]interface{})
|
||||
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
|
||||
@@ -366,22 +474,18 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
|
||||
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
calls := parseDryRunAPI(t, WorkbookExport, []string{
|
||||
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
|
||||
"--output-path", "/tmp/out.csv",
|
||||
})
|
||||
if len(calls) != 3 {
|
||||
t.Fatalf("api calls = %d, want 3", len(calls))
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("api calls = %d, want 1", len(calls))
|
||||
}
|
||||
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
|
||||
if body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export missing sub_id: %#v", body)
|
||||
}
|
||||
dl := calls[2].(map[string]interface{})
|
||||
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
|
||||
t.Errorf("download url = %v", dl["url"])
|
||||
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
|
||||
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -396,92 +500,6 @@ func TestWorkbookExport_DryRun(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkbookExportDownloadErrorClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves typed request errors", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := errs.NewAPIError(errs.SubtypeServerError, "typed upstream").WithCode(123)
|
||||
got := sheetsDownloadRequestError(in)
|
||||
if got != in {
|
||||
t.Fatalf("typed error was not preserved: got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps raw request errors as network transport", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadRequestError(errors.New("dial refused"))
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantCategory errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetryable bool
|
||||
}{
|
||||
{
|
||||
name: "5xx is retryable network server error",
|
||||
status: http.StatusBadGateway,
|
||||
wantCategory: errs.CategoryNetwork,
|
||||
wantSubtype: errs.SubtypeNetworkServer,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "404 is API not found",
|
||||
status: http.StatusNotFound,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeNotFound,
|
||||
},
|
||||
{
|
||||
name: "429 is retryable API rate limit",
|
||||
status: http.StatusTooManyRequests,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeRateLimit,
|
||||
wantRetryable: true,
|
||||
},
|
||||
{
|
||||
name: "other 4xx is API unknown",
|
||||
status: http.StatusForbidden,
|
||||
wantCategory: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := sheetsDownloadHTTPStatusError(&larkcore.ApiResp{
|
||||
StatusCode: tt.status,
|
||||
RawBody: []byte("body"),
|
||||
Header: http.Header{larkcore.HttpHeaderKeyLogId: []string{"log123"}},
|
||||
})
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T %v", got, got)
|
||||
}
|
||||
if p.Category != tt.wantCategory || p.Subtype != tt.wantSubtype {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", p.Category, p.Subtype, tt.wantCategory, tt.wantSubtype)
|
||||
}
|
||||
if p.Code != tt.status {
|
||||
t.Fatalf("code = %d, want %d", p.Code, tt.status)
|
||||
}
|
||||
if p.LogID != "log123" {
|
||||
t.Fatalf("log_id = %q, want log123", p.LogID)
|
||||
}
|
||||
if p.Retryable != tt.wantRetryable {
|
||||
t.Fatalf("retryable = %v, want %v", p.Retryable, tt.wantRetryable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertInputEquals compares the decoded tool input map against the wanted
|
||||
// fields. Extra fields in `got` are allowed (defaults, optional fields);
|
||||
// every key in `want` must match exactly.
|
||||
|
||||
@@ -56,7 +56,7 @@ var CellsSet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,6 +88,9 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input := map[string]interface{}{
|
||||
"excel_id": token,
|
||||
"range": strings.TrimSpace(runtime.Str("range")),
|
||||
@@ -129,7 +132,7 @@ var CellsSetStyle = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,12 +200,12 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
|
||||
// plain values. Use +cells-set for anything richer (formula / style / note).
|
||||
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
|
||||
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
|
||||
var CsvPut = common.Shortcut{
|
||||
Service: "sheets",
|
||||
Command: "+csv-put",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
|
||||
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
|
||||
Risk: "write",
|
||||
Scopes: []string{"sheets:spreadsheet:write_only"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
@@ -237,7 +240,7 @@ var CsvPut = common.Shortcut{
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -414,7 +417,7 @@ var DropdownSet = common.Shortcut{
|
||||
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -801,7 +804,7 @@ var CellsSetImage = common.Shortcut{
|
||||
Body(setCellBody)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, err := resolveSpreadsheetToken(runtime)
|
||||
token, err := resolveSpreadsheetTokenExec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -427,6 +428,44 @@ func TestCellsSet_RequiresJSONArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_RejectsUnsupportedMentionType pins the mention_type enum in
|
||||
// data/flag-schemas.json (synced from the upstream tool schema): a rich_text
|
||||
// mention whose mention_type is outside MENTION_FILE_TYPE (here 6 = cloud
|
||||
// shared folder) is rejected by the schema validator at flag-parse time,
|
||||
// before it can reach the server and blow up pb serialization
|
||||
// ("mentionFileInfo.fileType: enum value expected").
|
||||
func TestCellsSet_RejectsUnsupportedMentionType(t *testing.T) {
|
||||
t.Parallel()
|
||||
cells := `[[{"rich_text":[{"type":"mention","text":"x","mention_type":6,"mention_token":"t"}]}]]`
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells, "--dry-run",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "mention_type") || !strings.Contains(combined, "not in enum") {
|
||||
t.Errorf("expected mention_type enum guard; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSet_AllowsValidMentionTypes confirms the guard lets through a
|
||||
// user @mention (mention_type 0) and a render-supported file type (22 = DOCX).
|
||||
func TestCellsSet_AllowsValidMentionTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mt := range []int{0, 22} {
|
||||
cells := fmt.Sprintf(`[[{"rich_text":[{"type":"mention","text":"x","mention_type":%d,"mention_token":"t"}]}]]`, mt)
|
||||
stdout, stderr, err := runShortcutCapturingErr(t, CellsSet, []string{
|
||||
"--url", testURL, "--sheet-id", testSheetID,
|
||||
"--range", "A1", "--cells", cells, "--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("mention_type %d: unexpected error: stdout=%s stderr=%s err=%v", mt, stdout, stderr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellsSetImage_DryRun verifies the 2-step plan (upload + embed) is
|
||||
// rendered, including the parent_type=sheet_image upload metadata.
|
||||
func TestCellsSetImage_DryRun(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package sheets
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// Shortcuts returns all lark-sheets shortcuts. The list is grouped by
|
||||
// canonical skill to mirror the sheet-skill-spec layout
|
||||
@@ -22,10 +26,46 @@ func Shortcuts() []common.Shortcut {
|
||||
if _, ok := commandsWithSchema[all[i].Command]; ok {
|
||||
all[i].PrintFlagSchema = printFlagSchemaFor(all[i].Command)
|
||||
}
|
||||
// Accept --token as a parse-time alias for --spreadsheet-token (the
|
||||
// single highest-frequency reflex misspelling in eval traces) on every
|
||||
// shortcut that registers --spreadsheet-token, so the typo costs zero
|
||||
// round-trips instead of an unknown-flag failure. Wired through the
|
||||
// existing PostMount hook and composed onto any prior PostMount, so the
|
||||
// common framework needs no change at all.
|
||||
if hasFlag(all[i].Flags, "spreadsheet-token") {
|
||||
all[i].PostMount = withTokenAlias(all[i].PostMount)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func hasFlag(flags []common.Flag, name string) bool {
|
||||
for _, fl := range flags {
|
||||
if fl.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// withTokenAlias wraps an optional PostMount so that, after it runs, --token
|
||||
// resolves to --spreadsheet-token at parse time via pflag's normalize hook (no
|
||||
// duplicate flag in --help). It preserves any pre-existing PostMount — e.g.
|
||||
// +csv-put's --range / --start-cell flag-group setup — by running it first.
|
||||
func withTokenAlias(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
|
||||
return func(cmd *cobra.Command) {
|
||||
if prev != nil {
|
||||
prev(cmd)
|
||||
}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
if name == "token" {
|
||||
return pflag.NormalizedName("spreadsheet-token")
|
||||
}
|
||||
return pflag.NormalizedName(name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func shortcutList() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
// lark_sheet_workbook
|
||||
@@ -38,8 +78,11 @@ func shortcutList() []common.Shortcut {
|
||||
SheetHide,
|
||||
SheetUnhide,
|
||||
SheetSetTabColor,
|
||||
SheetShowGridline,
|
||||
SheetHideGridline,
|
||||
WorkbookCreate,
|
||||
WorkbookExport,
|
||||
WorkbookImport,
|
||||
|
||||
// lark_sheet_sheet_structure
|
||||
SheetInfo,
|
||||
@@ -56,6 +99,7 @@ func shortcutList() []common.Shortcut {
|
||||
CellsGet,
|
||||
CsvGet,
|
||||
DropdownGet,
|
||||
TableGet,
|
||||
|
||||
// lark_sheet_search_replace
|
||||
CellsSearch,
|
||||
@@ -67,6 +111,7 @@ func shortcutList() []common.Shortcut {
|
||||
CellsSetImage,
|
||||
CsvPut,
|
||||
DropdownSet,
|
||||
TablePut,
|
||||
|
||||
// lark_sheet_range_operations
|
||||
CellsClear,
|
||||
|
||||
53
shortcuts/sheets/shortcuts_alias_test.go
Normal file
53
shortcuts/sheets/shortcuts_alias_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestWithTokenAlias verifies the PostMount-based --token → --spreadsheet-token
|
||||
// alias: it resolves at parse time, and it composes onto (rather than replaces)
|
||||
// any pre-existing PostMount — the property that lets it coexist with
|
||||
// +csv-put's --range/--start-cell flag-group setup.
|
||||
func TestWithTokenAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Alias resolves to the canonical flag.
|
||||
cmd := &cobra.Command{Use: "x"}
|
||||
cmd.Flags().String("spreadsheet-token", "", "")
|
||||
withTokenAlias(nil)(cmd)
|
||||
if err := cmd.Flags().Parse([]string{"--token", "shtABC"}); err != nil {
|
||||
t.Fatalf("--token should resolve as an alias: %v", err)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("spreadsheet-token").Value.String(); got != "shtABC" {
|
||||
t.Errorf("--token should set --spreadsheet-token; got %q", got)
|
||||
}
|
||||
|
||||
// Composes with an existing PostMount instead of dropping it.
|
||||
prevRan := false
|
||||
cmd2 := &cobra.Command{Use: "y"}
|
||||
cmd2.Flags().String("spreadsheet-token", "", "")
|
||||
withTokenAlias(func(_ *cobra.Command) { prevRan = true })(cmd2)
|
||||
if !prevRan {
|
||||
t.Error("pre-existing PostMount should still run")
|
||||
}
|
||||
if err := cmd2.Flags().Parse([]string{"--token", "shtZ"}); err != nil {
|
||||
t.Fatalf("--token should still resolve when composed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcuts_TokenAliasOnSpreadsheetTokenCommands asserts every shortcut that
|
||||
// takes --spreadsheet-token ends up with a PostMount (the composed token alias),
|
||||
// so the reflex typo is forgiven wherever the canonical flag exists.
|
||||
func TestShortcuts_TokenAliasOnSpreadsheetTokenCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, s := range Shortcuts() {
|
||||
if hasFlag(s.Flags, "spreadsheet-token") && s.PostMount == nil {
|
||||
t.Errorf("%s takes --spreadsheet-token but has no PostMount (token alias missing)", s.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,5 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
|
||||
// It deliberately creates the new page before deleting the old one so a create
|
||||
// failure cannot remove existing user content. The operation is not atomic.
|
||||
const replacePagesInitialRevisionID = -1
|
||||
|
||||
var SlidesReplacePages = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+replace-pages",
|
||||
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
return err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReplacePagesInput(pages)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
appendReplacePagesDryRunCalls(dry, resolved)
|
||||
return dry.
|
||||
Set("xml_presentation_id", resolved.PresentationID).
|
||||
Set("pages_count", len(resolved.Plan)).
|
||||
Set("plan", replacePagesPlanOutput(resolved.Plan)).
|
||||
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("validate-only") {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"plan": replacePagesPlanOutput(resolved.Plan),
|
||||
"status": "validated",
|
||||
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
revisionID := replacePagesInitialRevisionID
|
||||
results := make([]replacePageResult, 0, len(resolved.Plan))
|
||||
for i, item := range resolved.Plan {
|
||||
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
|
||||
results = append(results, result)
|
||||
if result.RevisionID != nil {
|
||||
revisionID = *result.RevisionID
|
||||
}
|
||||
if err != nil {
|
||||
if runtime.Bool("continue-on-error") {
|
||||
continue
|
||||
}
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"results": replacePageResultsOutput(results),
|
||||
"status": "completed",
|
||||
"summary": replacePagesSummaryOutput(results),
|
||||
"note": "batch replace is not atomic; each page was created before its old page was deleted",
|
||||
}
|
||||
if revisionID != replacePagesInitialRevisionID {
|
||||
out["revision_id"] = revisionID
|
||||
}
|
||||
if hasReplacePageFailures(results) {
|
||||
out["status"] = "partial_failure"
|
||||
return runtime.OutPartialFailure(out, nil)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type replacePageInput struct {
|
||||
SlideID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type replacePagePlanItem struct {
|
||||
OldSlideID string
|
||||
Content string
|
||||
Locator string
|
||||
}
|
||||
|
||||
type replacePagesPrepared struct {
|
||||
PresentationID string
|
||||
Plan []replacePagePlanItem
|
||||
}
|
||||
|
||||
type replacePageResult struct {
|
||||
OldSlideID string
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateReplacePagesInput(pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := buildReplacePagesPlan(pages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func parseReplacePages(raw string) ([]replacePageInput, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
out := make([]replacePageInput, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
p := replacePageInput{}
|
||||
if v, ok := m["slide_number"]; ok {
|
||||
_ = v
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
|
||||
}
|
||||
if v, ok := m["slide_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.SlideID = s
|
||||
}
|
||||
if v, ok := m["content"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.Content = s
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateReplacePagesInput(pages []replacePageInput) error {
|
||||
if len(pages) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, p := range pages {
|
||||
id := strings.TrimSpace(p.SlideID)
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
|
||||
}
|
||||
if err := validateCompleteSlideXML(p.Content); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteSlideXML(content string) error {
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
depth := 0
|
||||
seenRoot := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return fmt.Errorf("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return fmt.Errorf("root element is <%s>, want <slide>", t.Name.Local)
|
||||
}
|
||||
seenRoot = true
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && strings.TrimSpace(string(t)) != "" {
|
||||
return fmt.Errorf("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return fmt.Errorf("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return fmt.Errorf("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
|
||||
plan := make([]replacePagePlanItem, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
id := strings.TrimSpace(page.SlideID)
|
||||
plan = append(plan, replacePagePlanItem{
|
||||
OldSlideID: id,
|
||||
Content: page.Content,
|
||||
Locator: "slide_id",
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
|
||||
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
|
||||
for i, item := range resolved.Plan {
|
||||
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
})
|
||||
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": "<revision_returned_by_create>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
|
||||
result := replacePageResult{
|
||||
OldSlideID: item.OldSlideID,
|
||||
Status: "pending",
|
||||
}
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
createData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": revisionID},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
newSlideID := common.GetString(createData, "slide_id")
|
||||
if newSlideID == "" {
|
||||
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
|
||||
deleteData, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
slideURL,
|
||||
map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": revisionID,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "delete_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if rev, ok := revisionFromData(deleteData); ok {
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
result.Status = "replaced"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func revisionFromData(data map[string]interface{}) (int, bool) {
|
||||
if _, ok := data["revision_id"]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(common.GetFloat(data, "revision_id")), true
|
||||
}
|
||||
|
||||
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
out = append(out, map[string]interface{}{
|
||||
"old_slide_id": item.OldSlideID,
|
||||
"insert_before_slide_id": item.OldSlideID,
|
||||
"locator": item.Locator,
|
||||
"action": "create_before_then_delete_old",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(results))
|
||||
for _, result := range results {
|
||||
m := map[string]interface{}{
|
||||
"old_slide_id": result.OldSlideID,
|
||||
"status": result.Status,
|
||||
}
|
||||
if result.NewSlideID != "" {
|
||||
m["new_slide_id"] = result.NewSlideID
|
||||
}
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
|
||||
replaced := countReplacedPages(results)
|
||||
return map[string]interface{}{
|
||||
"replaced": replaced,
|
||||
"failed": len(results) - replaced,
|
||||
"total": len(results),
|
||||
}
|
||||
}
|
||||
|
||||
func countReplacedPages(results []replacePageResult) int {
|
||||
n := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "replaced" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasReplacePageFailures(results []replacePageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Status == "create_failed" || result.Status == "delete_failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody struct {
|
||||
Slide struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"slide"`
|
||||
BeforeSlideID string `json:"before_slide_id"`
|
||||
}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
|
||||
}
|
||||
if createBody.BeforeSlideID != "old2" {
|
||||
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
|
||||
}
|
||||
if !strings.Contains(createBody.Slide.Content, "<slide") {
|
||||
t.Fatalf("create content = %q", createBody.Slide.Content)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(12) {
|
||||
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["failed"] != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[
|
||||
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
|
||||
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
|
||||
]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
data := env.Data
|
||||
if data["status"] != "partial_failure" {
|
||||
t.Fatalf("status = %v, want partial_failure", data["status"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
|
||||
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if first["status"] != "create_failed" {
|
||||
t.Fatalf("first status = %v, want create_failed", first["status"])
|
||||
}
|
||||
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
|
||||
t.Fatalf("second result = %#v, want replaced with new2", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
results, _ := env.Data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["status"] != "delete_failed" {
|
||||
t.Fatalf("status = %v, want delete_failed", first["status"])
|
||||
}
|
||||
if first["new_slide_id"] != "new1" {
|
||||
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if out["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
|
||||
}
|
||||
plan, _ := out["plan"].([]interface{})
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan len = %d, want 1", len(plan))
|
||||
}
|
||||
item, _ := plan[0].(map[string]interface{})
|
||||
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
|
||||
t.Fatalf("plan item = %#v", item)
|
||||
}
|
||||
api, _ := out["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("api len = %d, want create/delete plan", len(api))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesValidationParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pages string
|
||||
}{
|
||||
{"empty pages", `[]`},
|
||||
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
|
||||
{"no locator", `[{"content":"<slide/>"}]`},
|
||||
{"empty content", `[{"slide_id":"s1","content":" "}]`},
|
||||
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
|
||||
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", tt.pages,
|
||||
"--as", "user",
|
||||
})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %v, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--pages" {
|
||||
t.Fatalf("Param = %q, want --pages", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type replacePagesEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
|
||||
t.Helper()
|
||||
var env replacePagesEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("missing data: %#v", env)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
|
||||
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
|
||||
// local files. The raw API returns Base64 image payloads; this shortcut keeps
|
||||
// those payloads out of stdout so agents only see small file metadata.
|
||||
var SlidesScreenshot = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
{Name: "output-name", Desc: "file name stem for --content render output"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
renderMode := runtime.Changed("content")
|
||||
if renderMode {
|
||||
if strings.TrimSpace(runtime.Str("content")) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
|
||||
}
|
||||
} else {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSlideScreenshotSelector(runtime) {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
}
|
||||
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Changed("content") {
|
||||
return dryRunRenderScreenshot(runtime)
|
||||
}
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
|
||||
}
|
||||
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if len(slideIDs) > 0 {
|
||||
body["slide_ids"] = slideIDs
|
||||
}
|
||||
if len(slideNumbers) > 0 {
|
||||
body["slide_numbers"] = slideNumbers
|
||||
}
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Body(body)
|
||||
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("content") {
|
||||
return executeRenderScreenshot(runtime)
|
||||
}
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
query := larkcore.QueryParams{}
|
||||
body := map[string]interface{}{}
|
||||
if len(slideIDs) > 0 {
|
||||
body["slide_ids"] = slideIDs
|
||||
}
|
||||
if len(slideNumbers) > 0 {
|
||||
body["slide_numbers"] = slideNumbers
|
||||
}
|
||||
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
|
||||
if err != nil {
|
||||
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
|
||||
}
|
||||
|
||||
saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"output_dir": outputDir,
|
||||
"screenshots": saved,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
content := runtime.Str("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
|
||||
}
|
||||
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
|
||||
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
|
||||
Body(map[string]interface{}{
|
||||
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
|
||||
})
|
||||
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
|
||||
}
|
||||
|
||||
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
|
||||
"content": content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"output_dir": outputDir,
|
||||
"screenshots": saved,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSlideIDs(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range values {
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSlideNumbers(values []int) ([]int, error) {
|
||||
out := make([]int, 0, len(values))
|
||||
seen := map[int]struct{}{}
|
||||
for _, n := range values {
|
||||
if n < 1 {
|
||||
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
|
||||
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
|
||||
}
|
||||
|
||||
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
|
||||
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
|
||||
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
|
||||
}
|
||||
return outputDir, nil
|
||||
}
|
||||
|
||||
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
|
||||
return validateScreenshotOutputDir(runtime, outputDir)
|
||||
}
|
||||
|
||||
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) {
|
||||
items := common.GetSlice(data, "slide_images")
|
||||
if len(items) == 0 {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
|
||||
}
|
||||
saved := make([]map[string]interface{}, 0, len(items))
|
||||
for i, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
|
||||
}
|
||||
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "")
|
||||
if err != nil {
|
||||
if isSlidesScreenshotPassthroughError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
|
||||
}
|
||||
saved = append(saved, item)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
|
||||
item := common.GetMap(data, "slide_image")
|
||||
if item == nil {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
|
||||
}
|
||||
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide")
|
||||
if err != nil {
|
||||
if isSlidesScreenshotPassthroughError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
|
||||
}
|
||||
return []map[string]interface{}{saved}, nil
|
||||
}
|
||||
|
||||
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
|
||||
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
|
||||
ext, label, err := slideScreenshotFormat(item)
|
||||
if err != nil {
|
||||
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
|
||||
}
|
||||
encoded := strings.TrimSpace(common.GetString(item, "data"))
|
||||
if encoded == "" {
|
||||
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
|
||||
}
|
||||
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
|
||||
}
|
||||
fileBase := strings.TrimSpace(outputName)
|
||||
if fileBase == "" {
|
||||
fileBase = slideID
|
||||
}
|
||||
if fileBase == "" {
|
||||
fileBase = fallbackName
|
||||
}
|
||||
path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"slide_id": slideID,
|
||||
"slide_number": slideScreenshotInt(item, "slide_number"),
|
||||
"format": label,
|
||||
"path": path,
|
||||
"size": len(imageBytes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
|
||||
presentationID = strings.TrimSpace(presentationID)
|
||||
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
|
||||
slideNumber := slideScreenshotInt(item, "slide_number")
|
||||
if presentationID != "" {
|
||||
switch {
|
||||
case slideNumber > 0 && slideID != "":
|
||||
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
|
||||
case slideNumber > 0:
|
||||
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
|
||||
case slideID != "":
|
||||
return fmt.Sprintf("%s_%s", presentationID, slideID)
|
||||
}
|
||||
}
|
||||
if slideID != "" {
|
||||
return slideID
|
||||
}
|
||||
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
|
||||
return fmt.Sprintf("slide-%d", slideNumber)
|
||||
}
|
||||
return fmt.Sprintf("slide-%d", index+1)
|
||||
}
|
||||
|
||||
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
|
||||
format := slideScreenshotInt(item, "format")
|
||||
switch format {
|
||||
case 1:
|
||||
return "png", "png", nil
|
||||
case 2:
|
||||
return "jpg", "jpeg", nil
|
||||
default:
|
||||
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
|
||||
}
|
||||
}
|
||||
|
||||
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if slideID != "" {
|
||||
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
|
||||
}
|
||||
|
||||
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if slideID != "" {
|
||||
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
|
||||
}
|
||||
|
||||
func slideScreenshotInt(item map[string]interface{}, key string) int {
|
||||
n, ok := util.ToFloat64(item[key])
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
QueryParams: query,
|
||||
}
|
||||
if body != nil {
|
||||
req.Body = body
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, errs.WrapInternal(err)
|
||||
}
|
||||
data, err := runtime.ClassifyAPIResponse(resp)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
|
||||
data["log_id"] = logID
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
|
||||
if len(slideNumbers) == 0 {
|
||||
return err
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if p.Hint == "" {
|
||||
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
|
||||
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isSlidesScreenshotPassthroughError(err error) bool {
|
||||
_, ok := errs.ProblemOf(err)
|
||||
return ok
|
||||
}
|
||||
|
||||
func summarizeScreenshotAPIData(v interface{}) interface{} {
|
||||
switch x := v.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]interface{}, len(x))
|
||||
for k, val := range x {
|
||||
out[k] = summarizeScreenshotAPIData(val)
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]interface{}, 0, len(x))
|
||||
for i, val := range x {
|
||||
if i >= 20 {
|
||||
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
|
||||
break
|
||||
}
|
||||
out = append(out, summarizeScreenshotAPIData(val))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if len(x) > 512 {
|
||||
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
|
||||
}
|
||||
return x
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
func safeScreenshotFileBase(base string) string {
|
||||
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
|
||||
name = strings.Trim(name, "._-")
|
||||
if name == "" {
|
||||
name = "slide"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
|
||||
base := safeScreenshotFileBase(fileBase)
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidateBase := base
|
||||
if i > 0 {
|
||||
candidateBase = fmt.Sprintf("%s_%d", base, i+1)
|
||||
}
|
||||
path := filepath.Join(outputDir, candidateBase+"."+ext)
|
||||
if _, err := runtime.FileIO().Stat(path); err == nil {
|
||||
continue
|
||||
} else if !isScreenshotFileNotExist(err) {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err)
|
||||
}
|
||||
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
|
||||
return "", common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err)
|
||||
}
|
||||
return resolvedPath, nil
|
||||
}
|
||||
path := filepath.Join(outputDir, base+"."+ext)
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path)
|
||||
}
|
||||
|
||||
func isScreenshotFileNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
imageBytes := []byte("png-bytes")
|
||||
jpegBytes := []byte("jpeg-bytes")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"slide_number": 2,
|
||||
"format": 2,
|
||||
"data": base64.StdEncoding.EncodeToString(jpegBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output-dir", "shots",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "shots", "pres_abc_slide_1.png")
|
||||
gotBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read screenshot: %v", err)
|
||||
}
|
||||
if string(gotBytes) != string(imageBytes) {
|
||||
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
|
||||
}
|
||||
jpegPath := filepath.Join(dir, "shots", "pres_abc_p002_slide_2.jpg")
|
||||
gotJPEGBytes, err := os.ReadFile(jpegPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read jpeg screenshot: %v", err)
|
||||
}
|
||||
if string(gotJPEGBytes) != string(jpegBytes) {
|
||||
t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes)
|
||||
}
|
||||
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
|
||||
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 2 {
|
||||
t.Fatalf("screenshots = %#v, want two items", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["slide_id"] != "slide_1" {
|
||||
t.Fatalf("slide_id = %v, want slide_1", item["slide_id"])
|
||||
}
|
||||
gotPath := item["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, filepath.Join("shots", "pres_abc_slide_1.png")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_slide_1.png suffix", item["path"])
|
||||
}
|
||||
item2, _ := items[1].(map[string]interface{})
|
||||
if item2["format"] != "jpeg" {
|
||||
t.Fatalf("format = %v, want jpeg", item2["format"])
|
||||
}
|
||||
gotPath2 := item2["path"].(string)
|
||||
if !filepath.IsAbs(gotPath2) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath2)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath2, filepath.Join("shots", "pres_abc_p002_slide_2.jpg")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_p002_slide_2.jpg suffix", item2["path"])
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" {
|
||||
t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_number": 2,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideNumbers []int `json:"slide_numbers"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 {
|
||||
t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers)
|
||||
}
|
||||
path := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_p002.png")
|
||||
if _, err := os.ReadFile(path); err != nil {
|
||||
t.Fatalf("read screenshot without slide_id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
outputDir := filepath.Join(dir, "shots")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
t.Fatalf("create output dir: %v", err)
|
||||
}
|
||||
existingPath := filepath.Join(outputDir, "pres_abc_p002.png")
|
||||
if err := os.WriteFile(existingPath, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatalf("write existing screenshot: %v", err)
|
||||
}
|
||||
|
||||
imageBytes := []byte("new-png")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_number": 2,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--output-dir", "shots",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gotExisting, err := os.ReadFile(existingPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read existing screenshot: %v", err)
|
||||
}
|
||||
if string(gotExisting) != "existing" {
|
||||
t.Fatalf("existing screenshot = %q, want unchanged", gotExisting)
|
||||
}
|
||||
newPath := filepath.Join(outputDir, "pres_abc_p002_2.png")
|
||||
gotNew, err := os.ReadFile(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read deduplicated screenshot: %v", err)
|
||||
}
|
||||
if string(gotNew) != string(imageBytes) {
|
||||
t.Fatalf("deduplicated screenshot = %q, want %q", gotNew, imageBytes)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "pres_abc_p002_2.png")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_p002_2.png suffix", item["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") {
|
||||
t.Fatalf("error = %v, want missing selector error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
|
||||
if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write input xml: %v", err)
|
||||
}
|
||||
imageBytes := []byte("rendered-png")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/slide_image/render",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_image": map[string]interface{}{
|
||||
"slide_id": "render_slide",
|
||||
"slide_number": 1,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", "@slide.xml",
|
||||
"--output-dir", "shots",
|
||||
"--output-name", "preview",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "shots", "preview.png")
|
||||
gotBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read rendered screenshot: %v", err)
|
||||
}
|
||||
if string(gotBytes) != string(imageBytes) {
|
||||
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
|
||||
}
|
||||
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
|
||||
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body.Content != content {
|
||||
t.Fatalf("content = %q, want input XML", body.Content)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) {
|
||||
t.Fatalf("path = %v, want shots/preview.png suffix", item["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
|
||||
t.Fatalf("error = %v, want content/slide selector conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--presentation cannot be used with --content") {
|
||||
t.Fatalf("error = %v, want presentation/content conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") {
|
||||
t.Fatalf("dry-run missing list endpoint: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "slide_numbers") {
|
||||
t.Fatalf("dry-run missing slide_numbers body: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render", func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/slide_image/render") {
|
||||
t.Fatalf("dry-run missing render endpoint: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "base64_output") {
|
||||
t.Fatalf("dry-run missing base64 suppression note: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output-dir", "../outside",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsafe output dir")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--output-dir invalid") {
|
||||
t.Fatalf("error = %v, want output-dir validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Tt-Logid": {"log-123"},
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"unexpected": "shape",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "pJJ",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if p.LogID != "log-123" {
|
||||
t.Fatalf("log_id = %v, want log-123", p.LogID)
|
||||
}
|
||||
if !strings.Contains(p.Message, "unexpected:shape") {
|
||||
t.Fatalf("message = %q, want raw_data summary", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotSlideNumberAPIErrorAddsHint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Tt-Logid": {"log-slide-number"},
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992402,
|
||||
"msg": "field validation failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "25",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if p.LogID != "log-slide-number" {
|
||||
t.Fatalf("log_id = %v, want log-slide-number", p.LogID)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--slide-id") {
|
||||
t.Fatalf("hint = %q, want --slide-id guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"presentation_id": "pres_abc",
|
||||
"revision_id": 7,
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "readback.xml",
|
||||
"--revision-id", "7",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "readback.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
t.Fatalf("revision_id query = %q, want 7", got)
|
||||
}
|
||||
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
|
||||
t.Fatalf("remove_attr_id query = %q, want true", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "readback.xml") {
|
||||
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "pres_real",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": `<presentation/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--output", "wiki.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_real" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "../readback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", problem.Param)
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,13 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.pptx` / `.pdf` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX/PDF 导入上限是 500MB。PDF 导入 Slides 通常可按每页约 10 秒预估处理时间,轮询或续查时不要过早放弃。
|
||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF 等)导入并转换为飞书在线云文档(docx、sheet、bitable、slides)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。
|
||||
将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等)导入并转换为飞书在线云文档(docx、sheet、bitable、slides)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。
|
||||
@@ -45,9 +45,8 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"
|
||||
# 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB)
|
||||
lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原"
|
||||
|
||||
# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB;PDF 可按每页约 10 秒预估导入处理时间)
|
||||
# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB)
|
||||
lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报"
|
||||
lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报"
|
||||
|
||||
# 导入到指定文件夹,并指定导入后的文件名
|
||||
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
|
||||
@@ -79,7 +78,6 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
|
||||
- **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为"导入到调用者根目录"。
|
||||
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格的 token,point 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。
|
||||
- **PDF 导入 Slides 耗时预估**:PDF 导入 Slides 是长耗时异步任务,通常按每页约 10 秒估算总处理时间。`drive +import` 内置轮询超时只表示本地等待窗口结束,不代表任务失败。看到 `ready=false` / `timed_out=true` 时,必须继续执行返回的 `next_command` 查询同一个 `ticket`,不要重试导入、不要提前放弃;至少等待到 `页数 * 10 秒` 的预估处理时间后,再结合任务状态判断是否异常。
|
||||
|
||||
### 支持的文件类型转换
|
||||
|
||||
@@ -96,7 +94,6 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
|
||||
| `.base` | `bitable` | 多维表格快照文件 |
|
||||
| `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 |
|
||||
| `.pdf` | `slides` | PDF 文档 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 用户口头说的 “Base” / “多维表格” / “bitable”,在命令里统一对应 `--type bitable`。
|
||||
@@ -106,7 +103,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
|
||||
> - `.xls` 文件**只能**导入为 `sheet`
|
||||
> - `.base` 文件**只能**导入为 `bitable`
|
||||
> - `.pptx` / `.pdf` 文件**只能**导入为 `slides`
|
||||
> - `.pptx` 文件**只能**导入为 `slides`
|
||||
> - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet`
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -140,7 +137,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
| `.csv` | `bitable` | 100MB |
|
||||
| `.xls` | `sheet` | 20MB |
|
||||
| `.base` | `bitable` | 20MB |
|
||||
| `.pptx`, `.pdf` | `slides` | 500MB |
|
||||
| `.pptx` | `slides` | 500MB |
|
||||
|
||||
- 如果文件超出对应上限,shortcut 会在真正上传前直接返回验证错误。
|
||||
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# 权限治理 Command Patterns
|
||||
|
||||
本文只提供 `permission_governance` workflow 的具体 `lark-cli` 命令样例。只有进入对应 state 且需要拼装命令时才读取本文;命令可用范围仍以 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) 的 `Command Map` 为准。
|
||||
|
||||
## 目录
|
||||
|
||||
- `目标解析`
|
||||
- `目标发现`
|
||||
- `事实读取`
|
||||
- `写前确认与执行`
|
||||
|
||||
## 目标解析
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
|
||||
发现 Wiki space / node 下目标:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
|
||||
发现 Drive folder 下目标:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200}' \
|
||||
--as user --format json
|
||||
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 事实读取
|
||||
|
||||
读取 metadata:
|
||||
|
||||
```bash
|
||||
lark-cli drive metas batch_query \
|
||||
--data '{"request_docs":[{"doc_token":"<token>","doc_type":"<type>"}],"with_url":true}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取 public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.statistics get \
|
||||
--params '{"file_token":"<token>","file_type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
按需读取最近访问记录:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.view_records list \
|
||||
--params '{"file_token":"<token>","file_type":"<type>","page_size":50}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 写前确认与执行
|
||||
|
||||
patch 前检查 manage-public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members auth \
|
||||
--params '{"token":"<token>","type":"<type>","action":"manage_public"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
patch 前读取当前 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.public.patch --format json
|
||||
```
|
||||
|
||||
只 patch 当前 schema 支持的字段;对 Wiki 目标,必须省略 schema 明确标注为 Wiki 不支持的字段。
|
||||
|
||||
显式确认后 patch public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public patch \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--data '{"link_share_entity":"closed","external_access":false}' \
|
||||
--as user --yes --format json
|
||||
```
|
||||
|
||||
显式确认后申请访问权限:
|
||||
|
||||
```bash
|
||||
lark-cli drive +apply-permission \
|
||||
--token '<url>' \
|
||||
--perm view --remark '<reason>' --as user --format json
|
||||
|
||||
lark-cli drive +apply-permission \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--perm view --remark '<reason>' --as user --format json
|
||||
```
|
||||
|
||||
owner 转移前读取当前 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.members.transfer_owner --format json
|
||||
```
|
||||
|
||||
显式确认后转移 owner:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members transfer_owner \
|
||||
--params '{"token":"<token>","type":"<type>","need_notification":true,"remove_old_owner":false,"old_owner_perm":"full_access","stay_put":true}' \
|
||||
--data '{"member_id":"<new_owner_open_id>","member_type":"openid"}' \
|
||||
--as user --yes --format json
|
||||
```
|
||||
|
||||
`member_type` 只能使用当前 schema 支持的值:`email`、`openid`、`userid`、`appid`。如果用户只给姓名,必须先解析为明确身份或要求用户补充;不要猜测 `member_id`。批量 owner 转移必须逐个目标顺序执行。
|
||||
|
||||
secure label 写前枚举可用标签:
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-list \
|
||||
--page-size 10 --lang zh \
|
||||
--as user --format json
|
||||
|
||||
lark-cli drive +secure-label-list \
|
||||
--page-size 10 --page-token '<PAGE_TOKEN>' --lang zh \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
当用户给出的是标签名称、密级文案或不确定的 label ID 时,必须先枚举并解析为 `label-id`;写入确认里展示目标标签名称和 ID。找不到唯一标签时,停止并让用户选择,不要猜测。
|
||||
|
||||
显式确认后更新 secure label:
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
```
|
||||
@@ -1,424 +0,0 @@
|
||||
# 权限治理输出模板
|
||||
|
||||
本文只提供 `permission_governance` workflow 的用户可见输出模板。默认先给简短摘要;只有用户要求完整表格、需要写入确认,或结果大到需要结构化展示时才读取本文。
|
||||
|
||||
## 目录
|
||||
|
||||
- `输出策略`
|
||||
- `Semantic Rendering`
|
||||
- `定位与治理动作`
|
||||
- `单目标公开性判断`
|
||||
- `多目标明确列表诊断`
|
||||
- `审计摘要`
|
||||
- `容器安全诊断报告摘要`
|
||||
- `可操作风险清单`
|
||||
- `治理选择交互`
|
||||
- `权限设置清单`
|
||||
- `访问复核清单`
|
||||
- `整改 dry-run`
|
||||
- `批量权限申请确认`
|
||||
- `owner 转移确认`
|
||||
- `确认请求`
|
||||
- `最终摘要`
|
||||
|
||||
## 输出策略
|
||||
|
||||
- 单目标默认输出审计摘要。
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
- 容器安全诊断里不要把 `external_access=true` / `external_access_entity=open` 简写成“高风险”或“外部泄露”;用户可见说法应为“允许对外分享,需 owner 复核;这不等于已经存在外部协作者”。
|
||||
- 风险对象展示按规模渐进披露:1-10 个全部展示;11-30 个展示全部高优先级待复核对象,中 / 低优先级只做分组摘要;31-100 个按高优先级待复核分组展示 Top 5 和数量;100+ 个只展示分组统计和 Top 样例。
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
|
||||
面向用户的主结论优先渲染 `per_target_permission_assessment` 中的语义状态,并使用用户当前语言;底层字段名只在 raw evidence、排障或完整清单中保留。下表给出字段值到业务表达的标准映射;其他语言应表达同等业务含义。
|
||||
|
||||
字段来源边界:下表同时覆盖官方 OpenAPI 语义和当前 / 未来 CLI schema。只有实际响应或当前 schema 返回的字段和值,才可渲染为确定状态;当前 installed CLI 未返回的字段(例如 `copy_entity`、`manage_collaborator_entity`、`external_access_entity`)或未出现的枚举值,只能在 raw response / schema 实际出现时使用,缺失时必须按 unknown / unsupported 处理,不要臆造。
|
||||
|
||||
| Raw field / value | Semantic State | 中文说法 | English phrasing |
|
||||
|-------------------|----------------|----------|------------------|
|
||||
| `link_share_entity=anyone_readable` | `link_access=public_readable` | 互联网上获得链接的任何人可阅读 | Anyone on the internet with the link can read |
|
||||
| `link_share_entity=anyone_editable` | `link_access=public_editable` | 互联网上获得链接的任何人可编辑 | Anyone on the internet with the link can edit |
|
||||
| `link_share_entity=partner_tenant_readable` | `link_access=partner_readable` | 关联组织内知道链接可读 | People in partner tenants with the link can read |
|
||||
| `link_share_entity=partner_tenant_editable` | `link_access=partner_editable` | 关联组织内知道链接可编辑 | People in partner tenants with the link can edit |
|
||||
| `link_share_entity=tenant_readable` | `link_access=tenant_readable` | 公司内知道链接可读 | People in the tenant with the link can read |
|
||||
| `link_share_entity=tenant_editable` | `link_access=tenant_editable` | 公司内知道链接可编辑 | People in the tenant with the link can edit |
|
||||
| link sharing empty / disabled | `link_access=closed` | 未开启链接分享 | Link sharing is disabled |
|
||||
| `external_access_entity=open` or `external_access=true` | `external_sharing=open` | 允许分享到组织外;不等于已经存在外部协作者 | External sharing is open; this does not mean external collaborators already exist |
|
||||
| `external_access_entity=allow_share_partner_tenant` | `external_sharing=partner_only` | 仅允许分享到关联组织 | Sharing is allowed only with partner tenants |
|
||||
| `external_access_entity=closed` or `external_access=false` | `external_sharing=closed` | 当前不允许分享到组织外 | External sharing is disabled |
|
||||
| `invite_external=true` | `external_invitation=enabled` | 当前允许邀请外部用户 | Inviting external users is enabled |
|
||||
| `invite_external=false` | `external_invitation=disabled` | 当前不允许邀请外部用户 | Inviting external users is disabled |
|
||||
| `share_entity=anyone` | `collaborator_org_scope=all_viewers_or_editors` | 所有可阅读或可编辑者可查看、添加、移除协作者 | All viewers or editors can view, add, and remove collaborators |
|
||||
| `share_entity=same_tenant` | `collaborator_org_scope=tenant_viewers_or_editors` | 组织内可阅读或可编辑者可查看、添加、移除协作者 | Tenant viewers or editors can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_can_view` | `collaborator_permission_scope=viewer` | 拥有可阅读权限的协作者可查看、添加、移除协作者 | Collaborators with view permission can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_can_edit` | `collaborator_permission_scope=editor` | 拥有可编辑权限的协作者可查看、添加、移除协作者 | Collaborators with edit permission can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_full_access` | `collaborator_permission_scope=full_access` | 拥有可管理权限的协作者可查看、添加、移除协作者 | Collaborators with full-access permission can view, add, and remove collaborators |
|
||||
| `copy_entity=anyone_can_view` | `copy_scope=viewer` | 拥有可阅读权限的用户可复制内容 | Users with view permission can copy content |
|
||||
| `copy_entity=anyone_can_edit` | `copy_scope=editor` | 拥有可编辑权限的用户可复制内容 | Users with edit permission can copy content |
|
||||
| `copy_entity=only_full_access` | `copy_scope=full_access` | 仅拥有可管理权限的协作者可复制内容 | Only collaborators with full-access permission can copy content |
|
||||
| `security_entity=anyone_can_view` | `security_scope=viewer` | 拥有可阅读权限的用户可创建副本、打印、下载 | Users with view permission can create copies, print, and download |
|
||||
| `security_entity=anyone_can_edit` | `security_scope=editor` | 拥有可编辑权限的用户可创建副本、打印、下载 | Users with edit permission can create copies, print, and download |
|
||||
| `security_entity=only_full_access` | `security_scope=full_access` | 仅拥有可管理权限的用户可创建副本、打印、下载 | Only users with full-access permission can create copies, print, and download |
|
||||
| `comment_entity=anyone_can_view` | `comment_scope=viewer` | 拥有可阅读权限的用户可评论 | Users with view permission can comment |
|
||||
| `comment_entity=anyone_can_edit` | `comment_scope=editor` | 拥有可编辑权限的用户可评论 | Users with edit permission can comment |
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
|
||||
风险对象必须能让用户直接定位和处理:
|
||||
|
||||
- 摘要中的每个优先处理对象必须包含 `risk_id`、`path/title`、`URL`、`type`、owner、sec_label、风险原因、关键证据和建议动作。
|
||||
- 完整清单、访问复核清单、整改 dry-run 和写入确认都必须包含 URL。缺少 URL 时,展示 token / node_token,并说明 URL 未能获取。
|
||||
- 同名文档、shortcut 或副本必须用 path + URL 区分;不要只输出 title。
|
||||
- 完整风险清单中的每条记录必须有稳定 `risk_id`,格式为 `PG-001`、`PG-002`。`risk_id` 在同一次诊断和后续 dry-run / 确认 / 验证中保持不变。
|
||||
- 即使摘要只展示 Top 样例,也必须给样例分配稳定 `risk_id`;不能输出无法选择的标题列表。
|
||||
- 建议动作必须和风险类型绑定:互联网公开链接优先建议关闭链接分享或收紧为组织内;允许对外分享优先建议 owner 复核或关闭对外分享;缺少密级标签优先建议补齐密级;复制 / 下载 / 评论范围只在用户 policy 明确时建议收紧。
|
||||
- 写入动作只能作为下一步选项或确认请求出现。不要在诊断摘要里暗示已经执行缩权。
|
||||
|
||||
## 单目标公开性判断
|
||||
|
||||
当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,使用此模板。默认渲染 `target_count=1` 的 `per_target_permission_assessment`,跟随用户当前语言,不直接展示底层字段名;用户要求 raw evidence 时,再追加字段证据。
|
||||
|
||||
中文模板:
|
||||
|
||||
```text
|
||||
结论:<不是对外公开 / 存在互联网公开链接 / 允许对外分享>。
|
||||
|
||||
目标:<title>
|
||||
URL:<url-or-token-if-url-unavailable>
|
||||
类型:<type>
|
||||
|
||||
当前链接访问范围:<render link_access>
|
||||
对外分享:<render external_sharing>
|
||||
外部邀请:<render external_invitation or omit if unknown because field is absent>
|
||||
协作者管理(组织维度):<render collaborator_org_scope>
|
||||
协作者管理(权限维度):<render collaborator_permission_scope or omit if unknown because field is absent>
|
||||
复制内容:<render copy_scope or omit if unknown because field is absent>
|
||||
创建副本 / 打印 / 下载:<render security_scope>
|
||||
评论:<render comment_scope>
|
||||
Wiki 继承限制:<render lock_state or omit if unknown because field is absent>
|
||||
|
||||
检查边界:<render check_scope>
|
||||
```
|
||||
|
||||
English template:
|
||||
|
||||
```text
|
||||
Conclusion: <Not publicly accessible on the internet / A public internet link is enabled / External sharing is enabled>.
|
||||
|
||||
Target: <title>
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
Type: <type>
|
||||
|
||||
Current link access: <render link_access>
|
||||
External sharing: <render external_sharing>
|
||||
External invitations: <render external_invitation or omit if unknown because field is absent>
|
||||
Collaborator management by tenant: <render collaborator_org_scope>
|
||||
Collaborator management by permission: <render collaborator_permission_scope or omit if unknown because field is absent>
|
||||
Copy content: <render copy_scope or omit if unknown because field is absent>
|
||||
Create copies / print / download: <render security_scope>
|
||||
Comments: <render comment_scope>
|
||||
Wiki inheritance lock: <render lock_state or omit if unknown because field is absent>
|
||||
|
||||
Check boundary: <render check_scope>
|
||||
```
|
||||
|
||||
Raw evidence, only when requested:
|
||||
|
||||
```text
|
||||
Evidence fields:
|
||||
- link_share_entity=<value>
|
||||
- external_access_entity=<value>
|
||||
- external_access=<value>
|
||||
- invite_external=<value>
|
||||
- share_entity=<value>
|
||||
- manage_collaborator_entity=<value>
|
||||
- copy_entity=<value>
|
||||
- security_entity=<value>
|
||||
- comment_entity=<value>
|
||||
- lock_switch=<value>
|
||||
```
|
||||
|
||||
## 多目标明确列表诊断
|
||||
|
||||
当 `target_scope=explicit_list` 时,使用此模板。该场景不执行容器递归发现;对用户提供的每个 URL / token 逐个生成 `per_target_permission_assessment`,再按风险分组聚合。权限语义和单目标、容器诊断完全复用,不新增判断模型。
|
||||
|
||||
```text
|
||||
已完成只读权限诊断,没有做任何权限修改。
|
||||
|
||||
一句话结论:<N> 个目标中,<risk_count> 个存在待复核权限风险;<internet_public_count> 个存在互联网公开链接候选,<external_access_count> 个允许对外分享,<unknown_count> 个无法完整判断。
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
- <risk_id-or-item_id> <path-or-title> (<type>)
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
结论:<not_public / public_link_enabled / external_sharing_enabled / policy_review / unknown>
|
||||
关键权限:<render link_access>; <render external_sharing>; <render security_scope>; <render comment_scope>
|
||||
密级:<sec_label_name-or-missing-or-unknown>
|
||||
待复核原因:<risk reason or none>
|
||||
建议动作:<recommended action or no action>
|
||||
|
||||
分组摘要:
|
||||
- 互联网公开链接候选:<count>;允许对外分享:<count>;公司内链接可访问 / 可编辑:<count>
|
||||
- 复制 / 下载 / 打印 / 评论待策略确认:<count>;无法判断:<count and reason summary>
|
||||
|
||||
建议下一步:
|
||||
- 处理明确的 <risk_id>,先生成只读 dry-run。
|
||||
- 生成完整风险清单 artifact,后续可按 `risk_id`、风险分组、URL 或 `selected=true` 选择治理范围;只看权限设置时改用 `权限设置清单`。
|
||||
```
|
||||
|
||||
## 摘要清单展开规则
|
||||
|
||||
容器安全诊断的摘要必须兼顾可读性和可治理性。不要用固定 Top N 代替可处理清单。
|
||||
|
||||
| 风险对象数 | 摘要默认展示 | 必须提供的下一步 |
|
||||
|------------|--------------|------------------|
|
||||
| `0` | 只展示覆盖情况、未覆盖能力和剩余限制 | 如需更细审计,可生成权限设置清单 |
|
||||
| `1-10` | 展示全部风险对象 | 可直接按 `risk_id` 生成 dry-run 或写入确认 |
|
||||
| `11-30` | 展示全部高优先级待复核对象;中 / 低优先级做分组摘要 | 生成完整风险清单 artifact,或按风险分组生成 dry-run |
|
||||
| `31-100` | 每个高优先级待复核分组展示 Top 5,附未展示数量 | 生成 Markdown / CSV / 飞书文档完整风险清单 |
|
||||
| `100+` | 只展示分组统计、Top 样例和覆盖限制,不内联长表 | 强烈建议生成结构化风险清单后再选择治理范围 |
|
||||
|
||||
高优先级待复核对象包括:互联网公开链接、允许对外分享、允许对外分享且缺少 / 低于 policy 密级标签、公司内可编辑链接。协作者管理范围较宽默认归入中优先级待复核;只有用户 policy 明确要求严格协作者管理时才提升优先级。复制 / 下载 / 打印、评论范围在用户未提供明确 policy 时归入“待策略确认”,不要挤占高优先级清单。
|
||||
|
||||
摘要中的每个待复核对象必须包含 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、关键证据和建议动作。对同一底层文档的多个 Wiki 入口或 shortcut,必须用 URL 区分;如果建议合并治理,在建议动作里说明它们指向同一底层对象。
|
||||
|
||||
## 审计摘要
|
||||
|
||||
```text
|
||||
目标:<title> (<type>)
|
||||
URL:<url-or-token-if-url-unavailable>
|
||||
结论:<合规 / 待确认风险 / 无法完整判断>
|
||||
证据:
|
||||
- link_share_entity=<value>
|
||||
- external_access_entity=<value>
|
||||
- external_access=<value>
|
||||
- invite_external=<value>
|
||||
- share_entity=<value>
|
||||
- manage_collaborator_entity=<value>
|
||||
- copy_entity=<value>
|
||||
- security_entity=<value>
|
||||
- comment_entity=<value>
|
||||
- lock_switch=<value>
|
||||
- sec_label_name=<value-or-missing>
|
||||
限制:<unsupported_checks or none>
|
||||
建议动作:<read-only next step or proposed remediation>
|
||||
```
|
||||
|
||||
## 容器安全诊断报告摘要
|
||||
|
||||
```text
|
||||
已完成只读安全诊断,没有做任何权限修改。
|
||||
|
||||
一句话结论:<未发现互联网公开链接 / 存在互联网公开链接候选风险>;<external_access_count> 个文档允许对外分享,<missing_label_count> 个文档缺少密级标签。建议优先复核 <top_priority_group_or_paths>。
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
风险分级:
|
||||
- 高优先级待复核:<internet_public_count> 个互联网公开链接候选;<external_access_count> 个允许对外分享;其中 <external_without_label_count> 个同时缺少密级标签。
|
||||
- 中优先级待复核:<tenant_link_count> 个公司内知道链接可访问 / 可编辑;<wide_share_count> 个协作者管理范围较宽。
|
||||
- 待策略确认:<security_count> 个复制 / 下载 / 打印范围待复核;<comment_count> 个评论范围待复核。
|
||||
- 无法判断:<unsupported_or_unverified_summary>。
|
||||
|
||||
分级含义:
|
||||
- 互联网公开链接:获得链接的任何人可能访问,最高优先级。
|
||||
- 允许对外分享:外部分享能力已开启,需 owner 复核;不等于已经存在外部协作者。
|
||||
- 公司内链接可访问:不是对外公开,但组织内扩散范围较宽。
|
||||
- 复制 / 下载 / 打印 / 评论:是否需要收紧取决于业务 policy 和文档密级。
|
||||
|
||||
高优先级待复核清单:
|
||||
> 按 `摘要清单展开规则` 展示。每个对象必须包含 `risk_id` 和 URL;缺少 URL 时展示 token / node_token 和原因。若没有高优先级对象,只展示中优先级或待策略确认分组摘要。
|
||||
|
||||
- <risk_id> <path-or-title> (<type>)
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
Owner: <owner-or-unknown>
|
||||
密级:<sec_label_name-or-missing-or-unknown>
|
||||
待复核原因:<why high priority>
|
||||
证据:<short user-language evidence, e.g. 对外分享=已开启;链接分享=未开启互联网公开链接>
|
||||
建议动作:<recommended action>
|
||||
|
||||
未完全展开:
|
||||
- 完整风险清单包含 <risk_manifest_count> 条;本摘要已展示 <shown_count> 条,未展示 <hidden_count> 条。
|
||||
- 未展示分组:<risk_group=count summary or none>
|
||||
|
||||
建议下一步:
|
||||
- 生成完整风险清单 artifact,包含 `risk_id`、URL、owner、密级、证据字段、建议动作和 `selected` 列。
|
||||
- 基于 risk_id、风险分组、owner、路径、URL 或 artifact 中 `selected=true` 的行生成只读整改 dry-run。
|
||||
- 只针对最高优先级目标进入写入确认流程,例如关闭互联网公开链接或收紧对外分享;写入前仍需二次确认。
|
||||
- 按 owner / 密级生成复核清单。
|
||||
- 继续读取访问记录,判断低活跃高暴露。
|
||||
|
||||
剩余限制:
|
||||
- <do not claim collaborator-list verification if unsupported>
|
||||
- <external_access_entity=open or external_access=true only means sharing outside is allowed, not that external collaborators exist>
|
||||
- <missing view_records / DLP / AI index status / audit log limitations>
|
||||
```
|
||||
|
||||
## 可操作风险清单
|
||||
|
||||
完整风险清单用于让用户选择后续治理范围。Markdown / CSV / 飞书文档报告都必须包含以下字段;如果某种格式无法完整展示嵌套证据,使用短文本摘要,保留 `risk_id` 和 URL。
|
||||
|
||||
```text
|
||||
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
|
||||
生成时间:<timestamp>
|
||||
用途:用户可按 risk_id、priority、risk_group、owner、path、URL 或 selected=true 选择治理对象。
|
||||
|
||||
| risk_id | priority | Path | URL | Type | Owner | sec_label | risk_group | evidence | recommended_action | current_setting | target_setting | selected | decision | status | skip_reason |
|
||||
|---------|----------|------|-----|------|-------|-----------|------------|----------|--------------------|-----------------|----------------|----------|----------|--------|-------------|
|
||||
| PG-001 | P1 | <path> | <url-or-token> | <type> | <owner-or-unknown> | <sec-label-or-missing> | <risk_group> | <short evidence> | <recommended-action> | <field=value> | <field=value-or-owner-review> | false | undecided | pending | <none-or-reason> |
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `risk_id` 按 priority、risk_group、normalized path、URL、canonical token / node_token 稳定排序生成;URL 缺失时必须使用 token / node_token 作为 tie-breaker。同名、同路径、shortcut 或多个 Wiki 入口不能只靠 path 生成编号;同一次诊断中不得重复。
|
||||
- `priority` 使用 `P0`、`P1`、`P2`、`PolicyReview`、`Unknown`;面向用户展示时可译为“最高优先级 / 高优先级待复核 / 中优先级待复核 / 待策略确认 / 无法判断”。
|
||||
- `selected` 默认 `false`;用户可在 CSV / 飞书文档表格中改为 `true`,或在聊天中直接说 “处理 PG-001、PG-003”。
|
||||
- `decision` 表示用户决策:`undecided`、`keep`、`dry_run`、`confirm_write`、`skip`。
|
||||
- `status` 表示执行状态:`pending`、`dry_run_ready`、`confirmed`、`executed`、`verified`、`failed`、`skipped`。
|
||||
- `target_setting` 是建议目标状态,不代表已执行;没有明确 policy 时只能写 owner review / policy review。
|
||||
|
||||
## 治理选择交互
|
||||
|
||||
用户基于完整风险清单继续治理时,Agent 必须先解析选择范围,再生成只读 dry-run:
|
||||
|
||||
```text
|
||||
可接受的用户选择:
|
||||
- 处理 PG-001、PG-003、PG-008,把互联网公开链接关闭。
|
||||
- 先处理所有 risk_group=internet_public_link,不处理 external_access_only。
|
||||
- 把 CSV / 飞书文档里 selected=true 的行生成整改 dry-run。
|
||||
- PG-003 先跳过,只处理 PG-001。
|
||||
|
||||
Agent 必须回复:
|
||||
- 已选择对象数:<count>
|
||||
- 选择来源:<risk_id list / risk_group / selected=true / URL / path>
|
||||
- 将执行的下一步:生成 dry-run;不执行写入
|
||||
- 需要跳过或重新确认的对象:<missing risk_id / unsupported / changed_since_report / no manage_public>
|
||||
```
|
||||
|
||||
如果用户选择来自旧报告或外部 artifact,生成 dry-run 前必须对所选目标重新读取当前权限。当前设置和报告快照不一致时,标记为 `changed_since_report`,不要直接沿用旧字段执行。
|
||||
|
||||
## 权限设置清单
|
||||
|
||||
```text
|
||||
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
|
||||
|
||||
| Path | URL | Type | link_share_entity | external_access_entity / external_access | invite_external | share_entity | manage_collaborator_entity | copy_entity | security_entity | comment_entity | lock_switch | sec_label_name | 建议动作 | 限制 |
|
||||
|------|-----|------|-------------------|------------------------------------------|-----------------|--------------|----------------------------|-------------|-----------------|----------------|-------------|----------------|----------|------|
|
||||
| <path> | <url-or-token> | <type> | <value> | <value> | <value-or-unknown> | <value> | <value-or-unknown> | <value-or-unknown> | <value> | <value> | <value-or-unknown> | <value-or-missing> | <recommended-action> | <unsupported-or-none> |
|
||||
```
|
||||
|
||||
## 访问复核清单
|
||||
|
||||
```text
|
||||
范围:<wiki_space / wiki_node / drive_folder / explicit_list> <name-or-id>
|
||||
复核对象数:<count>
|
||||
|
||||
| Owner | Path | URL | Type | 密级 | 风险标签 | 当前权限摘要 | 最近访问证据 | 建议动作 |
|
||||
|-------|------|-----|------|------|----------|--------------|--------------|----------|
|
||||
| <owner-or-unknown> | <path> | <url-or-token> | <type> | <sec-label-or-missing> | <labels> | <link/external/share/security/comment> | <uv/pv/last_view_or_unknown> | <keep / tighten / owner review / unsupported> |
|
||||
|
||||
限制:<unsupported_checks / discovery_blockers / none>
|
||||
```
|
||||
|
||||
## 整改 dry-run
|
||||
|
||||
```text
|
||||
将生成整改计划,不执行写入:
|
||||
- 范围:<scope>
|
||||
- 选择来源:<risk_id list / risk_group / selected=true artifact / URL list>
|
||||
- 候选目标数:<count>
|
||||
- 计划执行命令:<command family>
|
||||
- 重新读取:已对所选目标重新读取当前权限;changed_since_report=<count>
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
|
||||
## 批量权限申请确认
|
||||
|
||||
```text
|
||||
将逐个发起 <view / edit> 权限申请:
|
||||
- 候选目标数:<count>
|
||||
- 命令类型:drive +apply-permission
|
||||
- 风险:write;每个请求都会通知 owner
|
||||
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
|
||||
|
||||
候选示例:
|
||||
- <risk_id> <title> (<type>, <url-or-token>):<reason>
|
||||
|
||||
请确认是否对上述候选目标发起权限申请。
|
||||
```
|
||||
|
||||
## owner 转移确认
|
||||
|
||||
```text
|
||||
将逐个转移 owner:
|
||||
- 候选目标数:<count>
|
||||
- 命令类型:drive permission.members transfer_owner
|
||||
- 风险:high-risk-write;会改变文档 owner,可能影响原 owner 权限和文档所在位置
|
||||
- 新 owner 映射:<same_new_owner / per_target_new_owner>
|
||||
- 全局新 owner:<member_id> (<member_type>);仅当所有候选目标的新 owner 相同时展示,否则省略
|
||||
- 通知新 owner:<need_notification>
|
||||
- 原 owner 权限:<remove_old_owner=true / old_owner_perm>
|
||||
- 个人空间位置:<stay_put>
|
||||
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
|
||||
- 验证方式:执行后重新读取 metadata owner;metadata 不支持的类型标记为 partial
|
||||
- 回滚边界:不做自动回滚;如需恢复 owner,必须另起一次反向 owner 转移确认
|
||||
|
||||
候选示例:
|
||||
- <risk_id> <title> (<type>, <url-or-token>):当前 owner=<owner-or-unknown> -> 新 owner=<member_id> (<member_type>)
|
||||
|
||||
请确认是否对上述候选目标转移 owner。
|
||||
```
|
||||
|
||||
## 确认请求
|
||||
|
||||
```text
|
||||
将执行 <operation>:
|
||||
- 目标:<risk_id> <title> (<type>, <url-or-token>)
|
||||
- 命令类型:<command family>
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
|
||||
## 最终摘要
|
||||
|
||||
```text
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
@@ -1,207 +0,0 @@
|
||||
# lark-drive 权限治理 Workflow
|
||||
|
||||
Workflow id: `permission_governance`
|
||||
|
||||
Risk / Structure: `R2` / `S2`
|
||||
|
||||
本文实现已注册的权限治理 workflow。执行前必须先读取 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
|
||||
|
||||
## 适用范围
|
||||
|
||||
当用户要求检查或治理 Drive / Docs / Wiki 资产访问权限时,使用本 workflow。典型意图包括:
|
||||
|
||||
- 单资源公开性、外部访问、公司内链接、分享 / 复制 / 下载 / 评论设置检查。
|
||||
- 多资源、Wiki space / node、Drive folder 或个人文档库的权限风险诊断和权限设置清单。
|
||||
- 访问复核、低活跃高暴露、权限申请、owner 转移、密级标签调整、AI Agent / RAG 前置权限治理。
|
||||
- 只读整改 dry-run,或经确认后的权限收紧 / 权限申请 / owner 转移 / 密级标签更新。
|
||||
|
||||
目标可以是明确 URL / token、小规模明确列表、Wiki space / Wiki node 或 Drive folder。容器范围必须先只读 `DISCOVER_TARGETS` 并产出覆盖摘要;这里的"所有文档"只表示当前身份在确认范围内可枚举到的文档。任何写入都必须再次确认。
|
||||
|
||||
单目标轻量路径:用户只问“是否对外公开 / 外部可访问 / 公司内链接可见”且目标是单个明确 URL / token 时,设置 `intent=public_exposure_check`、`target_scope=single_resource`,走 `PARSE_INTENT -> TARGET_INSPECT -> FACT_READ -> RISK_ASSESS -> DONE`。该路径是 `target_count=1` 的轻量输出模式,不是独立判断逻辑;不执行 `DISCOVER_TARGETS`、不生成 `risk_manifest` / `risk_id`,只输出结论、权限含义、检查边界和必要下一步。
|
||||
|
||||
## Target Set Evaluation
|
||||
|
||||
本 workflow 不按“单篇 / 多篇 / 容器”复制权限判断逻辑。所有范围先归一为 target set,再对每个可审计目标生成 `per_target_permission_assessment`,最后按目标数量和风险分组聚合输出。
|
||||
|
||||
| target_scope | Target Collection | Output Mode |
|
||||
|--------------|-------------------|-------------|
|
||||
| `single_resource` | 直接解析一个 URL / token | `target_count=1` 时轻量渲染;不生成 `risk_manifest` |
|
||||
| `explicit_list` | 用户给出的多个 URL / token 逐个 inspect / normalize | 逐目标渲染摘要;需要后续治理时生成稳定 `risk_id` |
|
||||
| `wiki_space` / `wiki_node` / `drive_folder` | 先只读递归发现,再归一化为 `discovered_targets` | 输出覆盖情况、风险分组、可定位待复核对象和 artifact / dry-run CTA |
|
||||
|
||||
特殊的是目标收集和输出聚合,不是权限语义。`link_access`、`external_sharing`、`copy_scope`、`security_scope`、`comment_scope`、`sec_label`、`check_scope` 等语义字段必须在单目标、多目标明确列表和容器发现目标之间复用。
|
||||
|
||||
## 非目标
|
||||
|
||||
本 workflow 不处理:
|
||||
|
||||
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
|
||||
- 内容审查、过期内容判断或知识质量评分。
|
||||
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。
|
||||
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
|
||||
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
|
||||
- 未按范围确认的批量写入。
|
||||
|
||||
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
本表只规定每个 state 需要加载的额外上下文;命令可用范围以 `Command Map` 为准。需要拼装具体 `lark-cli` 命令时,再按需读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。
|
||||
|
||||
| State | Required Reference |
|
||||
|-------|--------------------|
|
||||
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
|
||||
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
|
||||
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
|
||||
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
|
||||
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
|
||||
| `VERIFY` | 复用 `FACT_READ` 阶段使用的 read schemas |
|
||||
|
||||
## Runtime State Extension
|
||||
|
||||
本 workflow 在共享 `Artifact Contract` 基础上扩展以下字段组:
|
||||
|
||||
| Group | Fields | Meaning |
|
||||
|-------|--------|---------|
|
||||
| Scope | `intent`, `target_scope`, `targets`, `discovered_targets`, `coverage_summary`, `discovery_blockers` | 记录用户意图、确认范围、直接目标、容器发现目标和未覆盖范围 |
|
||||
| Facts | `metadata_facts`, `public_permission_facts`, `activity_facts`, `manage_public_auth` | 记录 metadata、公共访问与协作权限、访问证据,以及写前 `manage_public` 校验 |
|
||||
| Assessment | `per_target_permission_assessments`, `risk_findings`, `unsupported_checks` | 记录逐目标语义判断、带 `risk_id` / URL / owner / sec_label / evidence / action 的风险发现,以及无法执行的检查 |
|
||||
| Governance | `risk_manifest`, `selected_risk_items`, `access_review_items`, `permission_request_candidates`, `owner_transfer_candidates` | 支持用户按 `risk_id`、风险分组、owner、路径、URL 或 artifact `selected=true` 选择治理范围,并记录 owner 转移候选 |
|
||||
| Execution | `remediation_plan`, `owner_transfer_plan`, `public_permission_snapshots` | 记录 dry-run / 已确认整改计划、owner 转移计划、字段 diff、验证方式和 public-permission 有限回滚快照 |
|
||||
|
||||
## Execution State Machine
|
||||
|
||||
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
|
||||
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
|
||||
| `VERIFY` | `verify` | 重新执行支持的读取,并与目标状态对比 | 验证表和剩余缺口 | `false` | `DONE` |
|
||||
| `DONE` | `done` | 停止 | 最终回复,包含完成事项、验证结果和剩余风险 | `false` | End |
|
||||
|
||||
## Command Map
|
||||
|
||||
本 workflow 只能使用以下 command families:
|
||||
|
||||
| State | Allowed Command Families | Purpose |
|
||||
|-------|--------------------------|---------|
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
|
||||
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
|
||||
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
|
||||
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
|
||||
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
|
||||
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
|
||||
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
|
||||
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
|
||||
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 |
|
||||
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
|
||||
## Command Patterns
|
||||
|
||||
本入口不内联命令样例。需要拼装具体 `lark-cli` 命令时,按当前 state 读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。命令是否允许执行仍以 `Command Map` 和写入规则为准。
|
||||
|
||||
## Discovery Rules
|
||||
|
||||
容器范围只能先做只读发现和覆盖摘要,不能在发现阶段执行权限申请、权限 patch 或密级更新。
|
||||
|
||||
通用规则:
|
||||
|
||||
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。
|
||||
2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。
|
||||
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。
|
||||
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。
|
||||
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。
|
||||
|
||||
Wiki space / node 发现:
|
||||
|
||||
1. `/wiki/space/<space_id>` 直接解析为 `target_scope=wiki_space`。不要因为 `drive +inspect` 对该 URL 返回 not found 就停止。
|
||||
2. 用 `wiki +node-list --space-id <space_id>` 读取根节点;当节点 `has_child=true` 时,用该节点的 `node_token` 继续递归读取子节点。
|
||||
3. Wiki 节点必须同时保留 `node_token`、`obj_token` 和 `obj_type`。权限读取优先用 `type=wiki` + `node_token` 表达 Wiki 节点权限;元数据补充可使用 `obj_type` + `obj_token`。
|
||||
4. 如果节点只有 `obj_token` / `obj_type`,但无法确认 Wiki 节点权限 token,保留该目标为 partial,并在 `unsupported_checks` 中说明只能读取底层对象或无法完整判断 Wiki 节点权限。
|
||||
|
||||
Drive folder 发现:
|
||||
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
|
||||
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。
|
||||
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。
|
||||
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
|
||||
|
||||
## Fact Read Rules
|
||||
|
||||
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。
|
||||
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。
|
||||
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
|
||||
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。
|
||||
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
|
||||
8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
|
||||
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
|
||||
|
||||
## Risk Classification
|
||||
|
||||
风险标签只能作为 evidence labels。除非用户提供明确 policy,否则不要表述为绝对违规、已泄露或已外部访问。
|
||||
|
||||
默认优先级面向用户决策,而不是制造告警感:
|
||||
|
||||
- `P0`:`link_share_entity=anyone_readable/anyone_editable`,互联网公开链接候选风险。
|
||||
- `P1`:`external_access_entity=open` / `external_access=true`、关联组织访问、公司内链接可编辑,或外部分享且缺少 / 低于 policy 密级标签。
|
||||
- `P2`:公司内知道链接可读、协作者管理范围较宽。
|
||||
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
|
||||
- `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
|
||||
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
|
||||
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
|
||||
|
||||
## 写入规则
|
||||
|
||||
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
|
||||
- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
|
||||
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
|
||||
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
|
||||
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
|
||||
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
|
||||
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。
|
||||
- 用户显式确认批量权限申请后,也必须逐个目标顺序调用 `drive +apply-permission`,并在结果中区分已发起申请、失败、无法构造申请请求和未发现目标。
|
||||
- `drive permission.members transfer_owner` 属于 owner 转移高风险写入。必须先确认目标、当前 owner、新 owner 的 `member_id` / `member_type`、`need_notification`、`remove_old_owner`、`old_owner_perm`、`stay_put`、执行顺序和验证方式;不能只凭姓名猜测新 owner。
|
||||
- owner 转移没有 `permission.members auth` 的等价 precheck。执行前只能用 schema 和当前 metadata 做计划,执行后必须用 `drive metas batch_query` fresh read 验证 owner;metadata 不支持的类型必须把验证标记为 partial。
|
||||
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。
|
||||
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
|
||||
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
|
||||
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
|
||||
|
||||
## 未来扩展边界
|
||||
|
||||
以下能力已有部分 CLI surface 或用户价值,但不要在当前 workflow 中作为可执行分支直接调用:
|
||||
|
||||
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
|
||||
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。
|
||||
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
|
||||
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
|
||||
## 输出策略
|
||||
|
||||
- 默认 summary-first:单目标输出简短审计摘要;多目标明确列表输出逐目标摘要;容器目标输出安全诊断报告摘要,不堆叠字段计数。
|
||||
- 单目标 `public_exposure_check` 按 outputs 的 `Semantic Rendering` 渲染 `per_target_permission_assessment`,输出用户语言结论和检查边界;默认不展示底层字段名、风险清单或整改 CTA。
|
||||
- 容器安全诊断必须包含一句话结论、覆盖情况、风险分级、可定位待复核对象、建议下一步和剩余限制。
|
||||
- 待复核对象必须包含稳定 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、证据和建议动作;缺少 URL 时展示 token / node_token 和原因。
|
||||
- 容器摘要按规模渐进披露,不能固定 Top N;未完全展开时必须说明完整清单总数,并给出生成 artifact / dry-run / owner 复核清单等 CTA。
|
||||
- 面向用户优先使用业务语言和“候选风险 / 待复核 / 待策略确认”;底层字段只作为证据。完整模板按需读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md)。
|
||||
- 不要默认创建文件、飞书文档或长表格;最终回复必须包含已完成事项、验证结果和剩余限制。异步权限申请审批只能表述为“已发起申请”。
|
||||
@@ -1,130 +0,0 @@
|
||||
# lark-drive Workflow 总框架
|
||||
|
||||
本文是 `lark-drive` workflow 总框架的运行协议和注册表。它面向 AI Agent 执行,只负责路由已纳入本总框架的 workflow。
|
||||
|
||||
`Workflow Registry` 是本总框架的唯一注册来源。未命中 registry 的请求必须按“未注册 workflow 处理”执行,不要按已有 workflow 类推扩展。
|
||||
|
||||
## 必读上下文
|
||||
|
||||
执行本总框架内的 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
下游 reference 只能按需逐步加载。不要因为命中本总框架,就预加载所有 workflow 文件或相关 skill。
|
||||
|
||||
## 能力边界
|
||||
|
||||
`lark-drive` workflow 总框架以 `lark-drive` 作为 Drive / Docs / Wiki 资产编排的总入口。其他领域 skill 只有在已纳入本总框架的 workflow 明确需要时,才作为辅助能力加载。
|
||||
|
||||
| Layer | Owns | Must Not Own |
|
||||
|-------|------|--------------|
|
||||
| `lark-drive/SKILL.md` | 用户意图到具体 workflow entry 的短路由 | 长流程逻辑、未注册场景 |
|
||||
| `lark-drive-workflow.md` | 共享运行协议、Artifact Contract、Workflow Registry、加载规则 | 非运行时背景说明、宽泛路线图、场景专项执行细节 |
|
||||
| Registered workflow file | 场景范围、状态机、Command Map、确认门槛、验证规则 | 其他场景、隐藏写入、未被 CLI/API 支持的能力声明 |
|
||||
|
||||
## 执行协议
|
||||
|
||||
每个已纳入本总框架的 workflow 必须遵循同一条执行骨架:
|
||||
|
||||
```text
|
||||
route -> scope -> read -> assess/plan -> confirm -> execute -> verify -> done
|
||||
```
|
||||
|
||||
运行规则:
|
||||
|
||||
1. 在读取或写入资产前,先把用户意图解析到唯一一个已纳入本总框架的 workflow。
|
||||
2. 在昂贵读取或写入规划前,先解析并确认 `target_scope`。
|
||||
3. 事实必须来自可执行 CLI 命令或被引用 skill;不要只凭目录结构推断治理结论。
|
||||
4. 无法执行的检查必须记录到 `unsupported_checks`,不能静默省略。
|
||||
5. 写入前必须产出计划。每一次写入都需要用户对准确范围和 command family 显式确认。
|
||||
6. CLI/API 支持验证时,写入后必须用 fresh read 验证。
|
||||
7. 结束时进入 `done`,返回已完成事项、验证结果和剩余限制。不要把尚未完成的外部审批描述成已完成。
|
||||
|
||||
## Artifact Contract
|
||||
|
||||
每个已纳入本总框架的 workflow 必须维护以下内部字段:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `workflow_id` | 本总框架注册的 workflow 名称,例如 `permission_governance` |
|
||||
| `current_state` | 当前 workflow 状态 |
|
||||
| `target_scope` | 已确认的目标范围和用户原始输入 |
|
||||
| `identity` | 当前身份和执行视角,通常为 `user` |
|
||||
| `facts` | 从 CLI 读取或引用 skill 获取的证据 |
|
||||
| `plan_items` | 候选动作;每项包含 command family、target、risk、verification method |
|
||||
| `unsupported_checks` | 因 CLI/API 覆盖、目标类型、认证或范围限制而无法执行的检查 |
|
||||
| `partial` | 结果是否不完整,以及不完整原因 |
|
||||
| `execution_results` | 已确认写入的执行结果 |
|
||||
| `verification_results` | fresh read 验证结果,或明确的异步审批限制 |
|
||||
|
||||
用户可见输出默认使用简洁 chat summary。只有在用户要求、结果过大不适合聊天展示,或当前 workflow 明确要求共享产物时,才创建本地文件或飞书文档。
|
||||
|
||||
## Workflow Entry Contract
|
||||
|
||||
每个已纳入本总框架的 workflow entry file 必须让 Agent 能直接判断和执行:
|
||||
|
||||
- 何时进入该 workflow,以及哪些需求不属于该 workflow;
|
||||
- 如何映射到共享执行骨架的 state machine;
|
||||
- 当前 state 需要按需加载哪些 reference;
|
||||
- 哪些 command family 可用,以及读写风险边界;
|
||||
- 写入前如何确认,写入后如何验证;
|
||||
- 最终回复必须包含哪些字段,或使用哪些 output templates。
|
||||
|
||||
每个纳入本总框架的 workflow 默认从一个独立 reference 文件开始。只有当写入、回滚或验证流程复杂到影响可读性时,才继续拆 phase 文件。
|
||||
|
||||
## Risk / Structure Gate
|
||||
|
||||
每个纳入本总框架的 workflow 都必须同时声明 `Risk Level` 和 `Structure Level`。风险等级决定安全门槛;结构等级决定文件拆分。高风险写入不等于必须拆 phase。
|
||||
|
||||
Risk Level:
|
||||
|
||||
| Level | Meaning | Runtime Requirement |
|
||||
|-------|---------|---------------------|
|
||||
| `R0` | read-only:只读发现、分析、报告 | 记录事实来源、`unsupported_checks` 和 `partial` 原因 |
|
||||
| `R1` | low-risk write:创建草稿、生成临时产物等低风险写入 | 写前说明范围,写后返回结果链接或标识 |
|
||||
| `R2` | high-risk write:权限变更、批量移动、标签修改等高风险写入 | 写前计划、准确 diff、用户显式确认、fresh read 验证 |
|
||||
| `R3` | destructive / recovery-sensitive write:删除、自动归档、双向同步、rollback cleanup | 恢复边界、执行日志、分批策略、失败停止条件和单独确认 |
|
||||
|
||||
Structure Level:
|
||||
|
||||
| Level | File Shape | When To Use |
|
||||
|-------|------------|-------------|
|
||||
| `S1` | compact entry only | 只读、轻量审计、简单计划,无复杂写入 |
|
||||
| `S2` | entry + optional `commands` / `outputs` / `artifacts` references | 有命令样例、输出模板、少量高风险写入,但状态链可集中表达 |
|
||||
| `S3` | entry + phase files + optional shared references | 多阶段写入、复杂验证、恢复 / rollback、长任务或分批执行 |
|
||||
|
||||
升级规则:
|
||||
|
||||
1. 新 workflow 默认从 `S1` 开始。
|
||||
2. Entry file 超过约 300 行时,优先拆 `commands`、`outputs` 或 `artifacts` reference。
|
||||
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
|
||||
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template,不默认新增独立 workflow。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;已发布的独立 `knowledge_organize` 是 `R2-R3/S3`,当前不作为本总框架 registry entry。
|
||||
|
||||
## 加载与拆分边界
|
||||
|
||||
- 每个纳入本总框架的场景默认只保留一个紧凑 workflow entry file。
|
||||
- 不为未注册或未来场景创建占位 reference / registry entry。
|
||||
- 只有 workflow 已经具备可执行规则时,才允许作为本总框架 workflow 出现在 `SKILL.md` 并加入 `Workflow Registry`。
|
||||
- 多文件 phase 拆分只用于执行、回滚或验证流程复杂到影响可读性的 `S3` 场景。
|
||||
|
||||
## Workflow Registry
|
||||
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|---------|
|
||||
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
|
||||
|
||||
## Workflow Loading
|
||||
|
||||
当用户意图匹配到本总框架已注册 workflow 时:
|
||||
|
||||
1. 先读取本总框架文件。
|
||||
2. 只读取 `Workflow Registry` 中命中的 entry file。
|
||||
3. 按该 workflow 的 progressive load map 继续加载额外 reference。
|
||||
4. 除非用户改变意图,或当前 workflow 明确路由到其他 workflow,否则不要读取其他 workflow 文件。
|
||||
|
||||
## 未注册 workflow 处理
|
||||
|
||||
`Workflow Registry` 是本总框架的唯一注册来源。用户请求未列入 registry 的 workflow 或组合型治理场景时:
|
||||
|
||||
1. 明确说明该需求暂无纳入本总框架的 `lark-drive` workflow。
|
||||
2. 只在不新增本总框架 workflow 行为的前提下,将请求收窄为现有 skill / CLI 可执行的原子操作。
|
||||
3. 不要类比本总框架任何已注册 workflow 新增 state machine、artifact shape、风险分类、写入行为或验证结论。
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-sheets
|
||||
version: 2.0.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。"
|
||||
version: 3.0.0
|
||||
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -38,20 +38,27 @@ metadata:
|
||||
|
||||
| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) |
|
||||
| --- | --- | --- |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | — |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 读数据(纯值 / CSV) | `+csv-get`(范围用 `--range`) | `+get-range`、`+range-get`、`+cells-read` |
|
||||
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `+get-cell`、`+cell-get`、`--with-styles`、`--with-merges`、`--include-merged-cells` |
|
||||
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
|
||||
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put`(列显式声明 `type` + `format`,类型保真;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells) | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) |
|
||||
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`;date / number 不丢,详见 workbook) | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
|
||||
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range`) | — |
|
||||
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | — |
|
||||
| 插图:**自由摆放、不绑数据**的装饰 / 标识(logo / 水印 / 封面大图 / banner) | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | — |
|
||||
| 查找单元格 | `+cells-search`(关键字用 `--find`) | `+cells-find`、`+find`、`--query` |
|
||||
| 查找并替换 | `+cells-replace` | — |
|
||||
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get`、`+structure-get`、`+sheet-structure-get` |
|
||||
| 看工作簿 / 子表清单 | `+workbook-info` | — |
|
||||
| 看工作簿 / 子表清单 | `+workbook-info` | `+sheet-list`、`+workbook-get`、`+workbook-list` |
|
||||
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
|
||||
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable`) | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌 |
|
||||
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all) | `--type` |
|
||||
| 批量清除多区域 | `+cells-batch-clear`(`--scope`) | `--target` |
|
||||
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag) |
|
||||
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
|
||||
|
||||
> ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`;只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
|
||||
> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 → `+table-put`(写入已有表;声明 `type` + `format`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`);只有纯文本才用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`。
|
||||
> ⚠️ **定位 flag**:`+cells-get` / `+cells-set` / `+csv-get` 用 `--range`;`+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
|
||||
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`,**没有** `--with-styles` 这类 flag;**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
|
||||
|
||||
@@ -63,28 +70,28 @@ metadata:
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 |
|
||||
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 |
|
||||
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 |
|
||||
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 |
|
||||
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。 |
|
||||
|
||||
### 按对象的工具参考(含 shortcut)
|
||||
|
||||
| Reference | 描述 |
|
||||
| --- | --- |
|
||||
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 |
|
||||
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 |
|
||||
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 |
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。仅针对飞书表格。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。仅针对飞书表格。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 |
|
||||
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
|
||||
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据,应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 |
|
||||
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
|
||||
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
|
||||
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image);若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。 |
|
||||
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
|
||||
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
|
||||
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
|
||||
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
|
||||
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。 |
|
||||
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。 |
|
||||
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
|
||||
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
|
||||
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
|
||||
|
||||
## 公共 flag 速查
|
||||
|
||||
@@ -100,18 +107,18 @@ metadata:
|
||||
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选"):
|
||||
|
||||
1. **spreadsheet 定位(必填)**:`--url` 与 `--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
|
||||
- **`--url` 只解析 `/sheets/` 与 `/spreadsheets/` 两种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**:wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token,直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type` 为 `sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
|
||||
- **例外**:`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**(只有 `--title` / `--folder-token` / `--headers` / `--values`)。
|
||||
- **`--url` 解析 `/sheets/`、`/spreadsheets/` 与 `/wiki/` 三种链接**(从路径里抽出 token;也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
|
||||
- **`/wiki/` 知识库链接可直接传 `--url`**:会自动定位到链接背后的电子表格;若该链接背后不是电子表格(而是文档 / 多维表格等),则报错。
|
||||
- **例外**:`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets`,`+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`。
|
||||
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id,或上下文(之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试。
|
||||
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id` 或 `--sheet-name`,否则照样报上面的错。
|
||||
- ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:shell session 起手先 `set +H`** 关 bash history expansion,否则 `"Sheet1!A1"` 会被拦成 `event not found`;含特殊字符(`-` / 空格 / 非 ASCII)的 sheet 名还要内部 single-quote 包,如 `--source "'Sales-2025'!A1:D100"`。
|
||||
- ⚠️ **A1 reference 含 `!`**(`--source` / `--range` / `--ranges`)**:整段用单引号包裹**,如 `--range 'Sheet1!A1:B2'`——单引号能挡住 bash 的 history expansion(`!` 被拦成 `event not found`;双引号挡不住;别改用 `set +H`,原因见下方「复合 JSON / 大入参」)。sheet 名含特殊字符(`-` / 空格 / 非 ASCII)需在内部按 A1 标准再包一层单引号时,用 `'\''` 转义保持外层单引号,如 `--source ''\''Sales-2025'\''!A1:D100'`。
|
||||
- **例外**:徽章标为 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut(如 `+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create` 用 `--target-sheet-id` / `--target-sheet-name`(XOR,可都不传,落点细节见 `lark-sheets-pivot-table`)。
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet URL |
|
||||
| `--url` | string | 二选一必填(与 `--spreadsheet-token`) | spreadsheet 或 wiki URL |
|
||||
| `--spreadsheet-token` | string | 二选一必填(与 `--url`) | spreadsheet token |
|
||||
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut) | 工作表 reference_id |
|
||||
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut) | 工作表名称 |
|
||||
@@ -153,4 +160,6 @@ flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行
|
||||
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
|
||||
```
|
||||
|
||||
**参数含特殊字符(`!` / 引号 / 空格 / 非 ASCII)时,用单引号包裹该参数即可,不要起手 `set +H` 之类的 shell 开关来防转义。** `set +H`(关 bash history expansion)在 `sh` / `dash` 下是非法选项(`set: Illegal option -H`)、会让整条命令直接失败;而单引号挡得住 `!` 的 history expansion(否则报 `event not found`),对 bash 与 `sh` / `dash` 一致安全。参数本身含单引号、或 payload 较大时,按上文走 stdin。
|
||||
|
||||
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**:cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin(`--<flag> - < 文件`)。
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
|
||||
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本 skill 不重复。`+dropdown-delete` 不涉及这些 flag。
|
||||
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(`--colors` 长度可短不能长、必须配 `--highlight=true` 才生效、不传按内置 10 色色板循环补色)见 [`lark-sheets-write-cells`](./lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本文不重复。`+dropdown-delete` 不涉及这些 flag。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
- **默认情况(inline 模式)**:`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
|
||||
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
|
||||
- **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`:refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。
|
||||
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。
|
||||
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format` 或 `number_format`(schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。**日期轴同理**:横轴显示成 `45297` 这类 Excel 序列号,是因为源日期列没设日期格式——给源列设 `number_format="yyyy-mm-dd"` 后横轴才会显示成日期(反例:折线图横轴日期显示为序列号)。大数值轴显示科学计数法同理,给源列设整数 / 千分位格式(反例:透视表数值轴显示科学计数法)。
|
||||
- **轴口径要对齐用户要的指标**:用户要"占比 / 比例"时,**纵轴应是百分比**——用饼图,或柱 / 条形图设 `stack.percentage: true` 让纵轴变 %,并把数据源指向占比列 / 让数据标签显示百分比;不要交付纵轴仍是原始计数的图(反例:要求看各类占比,却用普通堆积柱、纵轴是 0–350 的人数而非百分比)。
|
||||
- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确
|
||||
|
||||
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。**
|
||||
@@ -66,7 +67,7 @@
|
||||
>
|
||||
> **反向约束**:场景 A 下不要写 `nameRef`——首行命名已经生效,多写反而冗余。`nameRef` 仅在场景 B 下使用(且必填)。
|
||||
|
||||
## ⚠️ chart 数据源引用 pivot 时必须排除总计行(高频致命错误)
|
||||
## ⚠️ chart 数据源引用 pivot 时必须排除总计行
|
||||
|
||||
当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去:
|
||||
- **柱形图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见
|
||||
@@ -84,15 +85,17 @@
|
||||
|
||||
1. **查尺寸**:`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount;`+sheet-info` 只返回布局,不含行列总数)。
|
||||
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。
|
||||
3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
|
||||
3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(`position.row` 为 **0-based**:首行 = `row:0`,与 A1 区间 / `+dim-insert --position` 的 1-based 行号不同;col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
|
||||
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
|
||||
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`;
|
||||
- 否则先调 `+dim-insert`(`lark-sheets-sheet-structure`)扩行/列,再 create。
|
||||
|
||||
⚠️ **图表落点禁止压在已有数据矩形内**——必须落在数据区**右侧或下方的空白**,否则图表浮层会遮挡原始数据被判失败(反例:折线图落在数据区中间,遮挡了下方原始数据)。
|
||||
|
||||
**示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
|
||||
- ❌ `{row: 0, col: "W"}` — col=22 越界
|
||||
- ✅ `{row: 42, col: "A"}` — 放数据下方
|
||||
- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U 列后插 6 列;U=index 20,after 即从 21 起),再放图到 `{row: 0, col: "V"}`
|
||||
- ✅ 先 `+dim-insert --position V --count 6`(在 V 列前插 6 列,即 U 列之后),再放图到 `{row: 0, col: "V"}`
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -147,9 +150,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
|
||||
_创建/更新的图表属性_
|
||||
|
||||
**顶层字段**:
|
||||
- `position` (object) — 必填 { row: number, col: string }
|
||||
- `position` (object?) — 必填 { row: number, col: string }
|
||||
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
|
||||
- `size` (object) — 必填 { width: number, height: number }
|
||||
- `size` (object?) — 必填 { width: number, height: number }
|
||||
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
|
||||
|
||||
## Examples
|
||||
@@ -164,24 +167,28 @@ _创建/更新的图表属性_
|
||||
|
||||
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**(1-based,对应 `refs.value` 范围内的列序)。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
|
||||
|
||||
> ⚠️ **含 `'Sheet'!` 前缀的 `--properties` 必须走 stdin 或 `@file`,不要用 inline 单引号**。`refs` / `nameRef` 里的 sheet 前缀带单引号(`'Sheet1'!A1`),若塞进 inline 的 `--properties '{...}'`,bash 会把内层那对单引号吃掉(sheet 名带空格还会被拆成多个词),JSON 直接被破坏。下面示例统一用 `--properties - <<'JSON' … JSON`(heredoc 定界符加引号 = 不做 shell 替换),或 `--properties @file.json`(`@` 只接 cwd 下相对路径)。
|
||||
|
||||
最小可用列图(inline 模式:refs 含表头行):
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--sheet-name "Sheet1" --properties '{
|
||||
"position":{"row":42,"col":"A"},
|
||||
"size":{"width":600,"height":400},
|
||||
"snapshot":{
|
||||
"data":{
|
||||
"refs":[{"value":"'Sheet1'!A1:B10"}],
|
||||
"dim1":{"serie":{"index":1}},
|
||||
"dim2":{"series":[{"index":2}]}
|
||||
},
|
||||
"plotArea":{"plot":{"type":"column"}}
|
||||
}
|
||||
}'
|
||||
--sheet-name "Sheet1" --properties - <<'JSON'
|
||||
{
|
||||
"position":{"row":42,"col":"A"},
|
||||
"size":{"width":600,"height":400},
|
||||
"snapshot":{
|
||||
"data":{
|
||||
"refs":[{"value":"'Sheet1'!A1:B10"}],
|
||||
"dim1":{"serie":{"index":1}},
|
||||
"dim2":{"series":[{"index":2}]}
|
||||
},
|
||||
"plotArea":{"plot":{"type":"column"}}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
# 走文件(推荐配置较多时)
|
||||
# 或落到 cwd 下相对路径文件再用 @file
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json
|
||||
```
|
||||
|
||||
@@ -190,7 +197,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @ch
|
||||
饼图比 column / bar 更复杂:`sectors` 是 object,里面再包一个**单数** `sector` 数组——CLI 不替你 normalize,写错路径会被 server schema 直接拒。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties - <<'JSON'
|
||||
{
|
||||
"position":{"row":24,"col":"F"},
|
||||
"size":{"width":600,"height":450},
|
||||
"snapshot":{
|
||||
@@ -208,7 +216,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
"dim2":{"series":[{"index":2,"aggregateType":"sum"}]}
|
||||
}
|
||||
}
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
**数据与表头分离(必须用 `detached` + `nameRef`)**:
|
||||
@@ -216,7 +225,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
|
||||
场景:周度销量明细表,真实表头在第 1 行(A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 11–17 行)。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
|
||||
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties - <<'JSON'
|
||||
{
|
||||
"position":{"row":7,"col":"F"},
|
||||
"size":{"width":600,"height":360},
|
||||
"snapshot":{
|
||||
@@ -233,7 +243,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
|
||||
]}
|
||||
}
|
||||
}
|
||||
}'
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- **日期/空值比较必须防空**:用户说"过期的标红"时,除了 `TODAY()`,公式必须排除空单元格,否则空白格也会被误判为"早于今天"而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
|
||||
- **公式条件注意引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 而非 `=$E$1<=TODAY()`,后者只比较一个格)
|
||||
|
||||
⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过(高频致命错误)**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成:
|
||||
⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成:
|
||||
|
||||
- "**增加辅助列**,再/然后标记……"
|
||||
- "**先计算/判断** XX **是否** YY,**再**标记……"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 概览
|
||||
|
||||
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill,本文用指针引到那里,不重复展开。
|
||||
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应 reference,本文用指针引到那里,不重复展开。
|
||||
|
||||
**三份「通用方法与规范」如何分工**(都不含 shortcut,按主题单一归属):
|
||||
|
||||
@@ -12,21 +12,21 @@
|
||||
|
||||
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
|
||||
|
||||
## 铁律(所有编辑类任务必须满足,子 skill 不得放宽)
|
||||
## 铁律(所有编辑类任务必须满足,各 reference 不得放宽)
|
||||
|
||||
1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。
|
||||
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
|
||||
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。
|
||||
1. **最小改动**:除用户明示要改的单元格 / 列外,原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet(新建允许,节制使用)。**改写 / 转换类任务要精确圈定适用行列**:只对任务真正要求的对象做变换,**不该转的行 / 列保持原值 1:1**(典型反例:要求"统一翻译"时把本就是中文、应原样保留的评论也重新翻译;要求"改写某列格式"时连原始测量值也一并改动 → 应保留的原文被篡改)。
|
||||
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。**收尾前必须确认产物文件真实存在 / 可导出**——别在没真正生成产物时只凭文本"已完成"就结束(反例:文本称已完成,实际没生成产物文件,等于没交付)。
|
||||
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
|
||||
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具(SORT / `TEXTBEFORE` / `MID` / 透视表 等)。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。**即使用户没说"联动 / 自动更新",凡是可由表内其它单元格推导的派生值(年龄=当年-出生年、占比=本类数/总数、达标=阈值判断、排名、各类分组汇总)默认就必须用公式**——用户默认期望派生列能随源数据重算,**离线 Python / 脚本算完写静态值,即便当前数值正确,改了源数据也不会自动更新,等于没满足"派生"的本意**(反例:年龄、月度汇总、占比、分组求和等派生列写死值,源数据一改结果就过时)。
|
||||
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
|
||||
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set),必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`。
|
||||
7. **分组汇总必须用透视表**:"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
|
||||
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。
|
||||
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert;多目标(删 N 行)每目标一个;多格式兼容(多种日期格式)每种至少一个样本;范围类(A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。**题面 / 表头里写明的格式规范也是子要点**:表头注明"需标注某字段"就必须给对应单元格加规定前缀并逐条 assert 前缀存在(反例:漏加规定前缀,该要点即不达标);"相同编号连续行合并"必须遍历所有相同编号组全部合并(反例:只合并了其中一部分组)。
|
||||
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
|
||||
|
||||
## 推荐工作流程
|
||||
|
||||
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill(避免读一个调一个),本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。
|
||||
1. **规划 reference 清单**:开工前一次性列出本任务要读的 reference(避免读一个调一个),本轮已读过的不重复读。本文 + `lark-sheets-workbook` 几乎每次都要。
|
||||
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure` 的 `+sheet-info`。
|
||||
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`)**:
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
| 需要公式 / 样式 / 批注 | C:`+cells-get` |
|
||||
| 续写 / 扩展 / 完善已有内容 | D:`+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见铁律 5) |
|
||||
|
||||
**【高频致命错误】** 对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。
|
||||
**注意**:对"完善 / 补齐 / 填空"类任务用路径 B 探 10 行就写入,实测会漏写表尾多行。写入前必须按 `lark-sheets-read-data`「确定数据范围的正确流程」确认真实数据末行。按关键字定位区域用 `lark-sheets-search-replace` 的 `+cells-search`。
|
||||
|
||||
4. **理解数据语义(写入前必做)**:读表头 + 3-5 行样本确认各列含义与格式(文本 / 数字 / 日期 / 混合);写公式前先分析样本值格式模式再选提取策略;建透视表前先列清"行字段=分组维度、值字段=聚合指标"。需求模糊时(如"加入加减乘除"未说逻辑)基于表头与已有公式推断,不确定就问用户,禁止臆造业务逻辑。
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**:BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`。
|
||||
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
|
||||
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
|
||||
- **写回的必须是纯单元格值,禁止把"值+样式标注"串当值写回**:本地脚本或某些 xlsx 解析库会把单元格渲染成 `甲方支行(V-Align: bottom)` 这种"值(样式)"字符串,CSV 字段还可能带包裹双引号。回写前必须**剥离括号样式标注、去掉残留引号**,只写原始值——否则样式描述会变成单元格的字面文本污染原数据(反例:排序后单元格值里被写进 `(V-Align: bottom)` 这类样式后缀文本,末尾还多一个双引号)。**排序本身优先用 `+range-sort` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。
|
||||
|
||||
## 公式策略
|
||||
|
||||
|
||||
@@ -109,6 +109,17 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
|
||||
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
|
||||
```
|
||||
|
||||
**`conditions[].type` × `compare_type` 取值**(`type` 决定可用的 `compare_type`;两者均必填):
|
||||
|
||||
| `type` | 可用 `compare_type` | `values` |
|
||||
|---|---|---|
|
||||
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
|
||||
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
|
||||
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
|
||||
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
|
||||
|
||||
> ⚠️ `text` 用 `equals` / `notEquals`(**带 s**),`number` / `multiValue` 用 `equal` / `notEqual`(**不带 s**)——别混。完整 schema 跑 `+filter-view-create --print-schema --flag-name properties`。
|
||||
|
||||
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
|
||||
|
||||
### `+filter-view-update`
|
||||
|
||||
@@ -102,6 +102,17 @@ lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
|
||||
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
|
||||
```
|
||||
|
||||
**`conditions[].type` × `compare_type` 取值**(`type` 决定可用的 `compare_type`;两者均必填):
|
||||
|
||||
| `type` | 可用 `compare_type` | `values` |
|
||||
|---|---|---|
|
||||
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
|
||||
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
|
||||
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
|
||||
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
|
||||
|
||||
> ⚠️ `text` 用 `equals` / `notEquals`(**带 s**),`number` / `multiValue` 用 `equal` / `notEqual`(**不带 s**)——别混。完整 schema 跑 `+filter-create --print-schema --flag-name properties`。
|
||||
|
||||
### `+filter-update`
|
||||
|
||||
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Lark Sheet Float Image
|
||||
|
||||
> **单元格图片 vs 浮动图片**:飞书表格有两种图片类型,请根据需求选择正确的工具:
|
||||
> - **单元格图片**:图片嵌入在单元格内部,随单元格移动,属于单元格内容的一部分。→ 使用 `+cells-set`,在 `rich_text` 中设置 `type: "embed-image"`(见 lark-sheets-write-cells)。
|
||||
> - **浮动图片**(本 Skill):图片悬浮在单元格上方,可自由指定位置、大小和层级,不属于任何单元格的内容。→ 使用本 Skill 的 `+float-image-{create|update|delete}`。
|
||||
> **选浮动图还是单元格图?只看一条**:这张图是不是**属于某条记录、要随那行一起排序 / 筛选 / 增删**?
|
||||
> - **是 → 单元格图片**(不在本 reference):嵌进单元格、随行走。用 `+cells-set-image`(或 `+cells-set` 的 `rich_text` + `type: "embed-image"`,见 lark-sheets-write-cells)。典型:凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图;话里带「对应 / 每行 / 每条 / 这列」等绑定词即属此类。
|
||||
> - **否 → 浮动图片**(本 reference):自由摆放、不绑数据的装饰 / 标识(logo / 水印 / 封面大图 / banner)。
|
||||
> - ⚠️ 别凭"浮动图位置尺寸更好控制 / 更熟"就选它——那是按操作便利选,不是按场景选;用浮动图承载"对应某记录"的图会在增删行 / 排序后错位。
|
||||
|
||||
## 真对象硬约束
|
||||
|
||||
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
|
||||
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set-image` / `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
|
||||
|
||||
## 使用场景
|
||||
|
||||
@@ -20,7 +21,7 @@
|
||||
典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **单元格图片 vs 浮动图片选择错误**:如果用户希望图片嵌入单元格内部(随单元格移动),应使用 `+cells-set` 的 `rich_text` + `embed-image`,而非本 Skill
|
||||
- **单元格图片 vs 浮动图片选择错误(最易选错)**:图与某条记录一一对应、要随行排序 / 筛选 / 增删时,应走 `+cells-set-image`(见顶部判别),用浮动图会错位。
|
||||
- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据
|
||||
- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确
|
||||
|
||||
@@ -30,7 +31,7 @@
|
||||
- `--image-token`:复用**已存在**的图片 file_token。常见来源:① `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。
|
||||
- `--image-uri`:图片 reference_id(image URI),由系统自动转 file_token。
|
||||
|
||||
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`manage_float_image` 工具强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。
|
||||
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -129,7 +130,7 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
|
||||
|
||||
### `+float-image-update`
|
||||
|
||||
> **update ≈ create,只有图片源可省**:`manage_float_image` 工具的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch:缺任一核心字段会被工具拒绝(`+float-image-list` 不回传 `image_name`,CLI 无法替你回填)。
|
||||
> **update ≈ create,只有图片源可省**:`+float-image-update` 的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch:缺任一核心字段会被拒绝(`+float-image-list` 不回传 `image_name`,CLI 无法替你回填)。
|
||||
>
|
||||
> 推荐流程:先 `+float-image-list --float-image-id <id>` 回读当前 position / size,再带上 `--image-name` 和完整的 position / size 调一次 `+float-image-update`。
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入)
|
||||
|
||||
飞书日期序列:`0 = 1899-12-30`,`1 = 1899-12-31`,没有 Excel 的 1900 年闰年兼容问题。
|
||||
|
||||
**高频错误写法(不要用):**
|
||||
**错误写法(不要用):**
|
||||
|
||||
- `=DAY(B2-A2)` ✗ — 差值会被当成日期序列号再拆字段
|
||||
- `=MONTH(B2-A2)` ✗
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
|
||||
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
|
||||
|
||||
**打印场景控制总宽(用户说"适合打印 / A4 / 打印范围"时必做)**:扩单列宽防截断的同时,**所有列宽之和要落在纸张可打印宽度内**——A4 横向约 ≤ 102 个半角字符(约 1000px),纵向约 ≤ 70 个字符。超宽时不要无限加宽,改用 `cell_styles.word_wrap="auto-wrap"` + 调高行高,或缩窄非关键列,让整表在一页内(反例:总列宽远超 A4 可打印宽度,且长文本行高不够被截断)。
|
||||
|
||||
**只加宽承载新内容的列,不改动原有列的列宽**:列宽自适应**只针对新增 / 真正放不下新内容的列**;原表已有列的列宽**禁止重新计算、禁止缩小**——即便你估算的"理想宽度"与原值不同,只要原内容没被截断就不要动它。无差别地把所有列重设一遍宽度(哪怕只 ±1)都属于破坏原文件视觉格式(反例:填完数据后顺手把原有列的列宽从 16 改成 17,与原附件不一致,破坏了原视觉格式)。
|
||||
|
||||
**⚠️ 合并单元格安全操作规则**(`+cells-{merge|unmerge}` 必读):
|
||||
|
||||
1. **先读后写**:操作前必须用 `+sheet-info --include merges` 或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。
|
||||
@@ -192,7 +196,7 @@ _排序条件列表(仅 sort 操作)_
|
||||
|
||||
## Examples
|
||||
|
||||
> ⚠️ 本 skill 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。skill 视角统一在这里讲解。
|
||||
> ⚠️ 本 reference 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。这里统一从区域操作视角讲解。
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
|
||||
|
||||
|
||||
@@ -13,61 +13,66 @@
|
||||
|
||||
预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。
|
||||
|
||||
⚠️ **大数字(15 位以上的身份证 / 参考号 / 流水号)做去重 / 比较时禁止用 `+csv-get` 的显示值**:`+csv-get` 返回的是**格式化显示值**,15 位以上数字会被显示成 `1.04E+14` 这类科学计数法——多个本不相同的号在显示层全变成同一个 `1.04E+14`,拿去判重会**整列误判为重复**。比较 / 去重 / 匹配大数字时必须改用 `+cells-get`(取原始精确值)或把该列读为文本,禁止用 csv-get 的科学计数显示值(反例:大批长参考号被显示成科学计数后,互不相同的号全变成同一个值,被当成整列重复并错误高亮)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut,按读取目的选择:
|
||||
读取。从飞书表格中读取单元格数据。本 reference 覆盖 4 个 shortcut,按读取目的选择:
|
||||
|
||||
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|
||||
|---------|----------------|---------|------|
|
||||
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(加 `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
|
||||
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
|
||||
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验 |
|
||||
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 |
|
||||
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
|
||||
|
||||
**选择原则**:
|
||||
- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文
|
||||
- 要结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token,超大表批量仍用默认)
|
||||
- 要按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`)→ `+table-get`
|
||||
- 需要公式/样式/批注 → `+cells-get`
|
||||
- 只想知道某区域下拉框有哪些选项 → `+dropdown-get`
|
||||
|
||||
⚠️ 超大数据请走"`+csv-get` 按 `--range` 行窗口(如 `A1:Z500` / `A501:Z1000` …)分批读到本地文件 + 本地脚本处理 + `+csv-put` 分批回写"。
|
||||
⚠️ **大数据优先落盘、别灌进上下文**:`+csv-get` / `+cells-get` 都受调用方 Bash / 终端的单命令 stdout 输出上限约束(常见默认约 30000 字符,超过会被截断或转存为文件)。纯值分析优先 `+csv-get --format csv` 按 `--range` 行窗口(`A1:Z500` / `A501:Z1000` …)分批重定向到文件 + 本地脚本处理 + `+csv-put` 分批回写;若确实要让结果直接进上下文又不想触发转存,给任一命令把 `--max-chars`(默认 500000)调小到略低于该上限(如 `25000`),CLI 改为优雅截断 + `has_more` 分页。
|
||||
|
||||
**`+csv-get` 返回值核心设计**:
|
||||
- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给本地脚本做解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`。
|
||||
- `col_indices` — **定位列字母唯一入口**。在表头中找到目标字段是第 j 个(0-based),用 `col_indices[j]` 取列字母。**禁止手数逗号**——列数超过 10 时极易 off-by-one(例如把 W 误判为 X)。
|
||||
- `row_indices` — 程序化引用的备用数组。LLM 推理请用 `annotated_csv` 的前缀,不要查这个数组里的 index(把行号当数值用容易心算出错)。
|
||||
- `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头、同时获知整表实际范围。
|
||||
- `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头。⚠️ 它**遇表中部整行空行 / 整列空列就截断**,可能小于真实数据范围(漏掉空行之后的行);**不能**直接当整表末行用,判断整表是否读全要拿 `+workbook-info` 的物理 `row_count` / `column_count` 当上界交叉核对(见下方「按 row_count 盲读空行」与「确定数据范围的正确流程」)。
|
||||
|
||||
注意:
|
||||
|
||||
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前还必须先看 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`
|
||||
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **全量读取导致上下文溢出(高频致命错误)**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
|
||||
- **全量读取导致上下文溢出**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
|
||||
- **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头:
|
||||
- **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N,则 `range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。
|
||||
- **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要靠 `current_region` 兜底。
|
||||
- **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要按下方「确定数据范围的正确流程」用 `+workbook-info` 的物理 `row_count` 交叉核对末行(`current_region` 遇空行会截断,不能单独兜底)。
|
||||
- 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。
|
||||
- **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get`
|
||||
- **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入
|
||||
- **直接按 `+cells-get` 返回二维数组下标推导真实位置(高频错误)**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位
|
||||
- **CSV 行号计数错误(高频致命错误)**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数
|
||||
- **手动数列确定列号(高频致命错误)**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母
|
||||
- **用数据列的值推导行号(高频致命错误,常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑
|
||||
- **按 `row_count` 盲读空行(高频低效)**:`+workbook-info` 的 `row_count` 是 sheet 的**网格物理行数**(常是 200 / 1000 等默认值),不是数据末行;按它把 `--range` 拉到 `S200`(实际数据可能只到 `S32`)会读回大片空行,浪费上下文又干扰判断。真实数据末行以 `+csv-get` 返回的 `current_region` 为准(它就是数据边界),再按下方「确定数据范围的正确流程」确认末行。
|
||||
- **current_region 当作纯数据范围(高频致命错误)**:`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」
|
||||
- **直接按 `+cells-get` 返回二维数组下标推导真实位置**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位
|
||||
- **CSV 行号计数错误**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数
|
||||
- **手动数列确定列号**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母
|
||||
- **用数据列的值推导行号(常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑
|
||||
- **`row_count` 与 `current_region` 都不能单独定末行**:`+workbook-info` 的 `row_count` 是 sheet 的**网格物理行数**(常是 200 / 1000 等默认值),通常**大于**真实数据末行——直接按它把 `--range` 拉到 `S200` 会读回大片空行,浪费上下文。反过来,`+csv-get` 返回的 `current_region` 是从锚点扩展、被空行空列围住的连续块,**遇表中部整行空行就截断**,可能**小于**真实数据范围(漏掉空行之后的行,典型反例:1–80 行有数据、81 行空、82 行起还有数据,`current_region` 只到 80,82 行起整段被漏读)。正确做法:把 `row_count` 当**上界**、`current_region` 当**起点参考**,在二者之间按下方「确定数据范围的正确流程」确认真实末行(含跨过中间空行的核对),不要只信其一。
|
||||
- **current_region 当作纯数据范围**:`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」
|
||||
|
||||
### 确定数据范围的正确流程(排序、筛选、批量写入等操作前必做)
|
||||
|
||||
当后续操作需要精确的数据范围(如排序、筛选、删除、批量写入)时,仅靠 `current_region` 探测到的范围是不够的——必须同时确认数据的**起始行**和**结束行**。具体步骤:
|
||||
当后续操作需要精确的数据范围(如排序、筛选、删除、批量写入)时,仅靠 `current_region` 探测到的范围是不够的——它**两头都可能不准**:表中部有整行空行时会被截断(末行偏小、漏数据),表尾有汇总 / 签名行时又会偏大。必须同时确认数据的**起始行**和**结束行**。具体步骤:
|
||||
|
||||
1. **确认起始行**:读取前 5~10 行,识别表头行位置,数据起始行 = 表头行 + 1
|
||||
2. **确认结束行**(关键步骤,不可跳过):读取 `current_region` 末尾附近的若干行(建议读取末尾 5~10 行),逐行检查内容,排除非数据行:
|
||||
- **汇总行**:内容为"合计"、"总计"、"小计"、"总计:"等
|
||||
- **签名/审批行**:内容为"编制人"、"审核人"、"部门负责人"等
|
||||
- **空行或分隔行**:整行为空或仅有边框
|
||||
- **备注/脚注行**:注释性文字、说明文字等
|
||||
3. **最终数据范围** = 起始行 ~ 最后一条有效数据行(排除非数据行)
|
||||
2. **确认结束行**(关键步骤,不可跳过):
|
||||
- **先防截断(漏数据)**:拿 `+workbook-info` 的物理 `row_count` 当上界,与 `current_region` 末行对比。若 `current_region` 末行 **远小于** `row_count`(差出很多空间),不要直接采信——在 `current_region` 末行之后再探一段(如往下读到 `row_count`,或分段扫到首个连续空白区),确认空行之后确实没有数据;典型反例:`row_count=327`、`current_region` 只到第 80 行,第 81 行空、82 行起还有数据,只读到 80 就漏了一大段。
|
||||
- **再排尾部非数据行**:读取确认到的末行附近若干行(建议末尾 5~10 行),逐行排除:
|
||||
- **汇总行**:内容为"合计"、"总计"、"小计"、"总计:"等
|
||||
- **签名/审批行**:内容为"编制人"、"审核人"、"部门负责人"等
|
||||
- **空行或分隔行**:整行为空或仅有边框
|
||||
- **备注/脚注行**:注释性文字、说明文字等
|
||||
3. **最终数据范围** = 起始行 ~ 最后一条有效数据行(跨过中间空行、排除尾部非数据行)
|
||||
|
||||
**示例**:`current_region` 返回 `A1:N51`,读取 Row 48~51 发现:
|
||||
|
||||
@@ -83,6 +88,7 @@
|
||||
| `+cells-get` | read | 单元格 |
|
||||
| `+dropdown-get` | read | 对象 |
|
||||
| `+csv-get` | read | 单元格 |
|
||||
| `+table-get` | read | 单元格 |
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -94,7 +100,7 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
|
||||
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation`) |
|
||||
| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000),以 has_more 分页 |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
|
||||
### `+dropdown-get`
|
||||
@@ -112,10 +118,21 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
|
||||
| `--max-chars` | int | optional | 防爆,默认 200000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
|
||||
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000),以 has_more 分页 |
|
||||
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
|
||||
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
|
||||
| `--rows-json` | bool | optional | 返回结构化 rows(`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` |
|
||||
|
||||
### `+table-get`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--sheet-id` | string | optional | 只读该子表(按 id);省略则读所有子表 |
|
||||
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
|
||||
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range(会跨过表中部的整行空行 / 整列空列,不会被截断) |
|
||||
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
|
||||
| `--dataframe-out` | string | optional | 以一份 Arrow IPC 文件(Feather v2)格式输出 typed 表格,替代默认的 JSON 输出。用 `@<path>` 传文件或 `-` 写二进制 stdout(同其他 binary I/O flag 的约定)。是 `+table-put` / `+workbook-create` 入口 `--dataframe` 的镜像 —— pandas 端 `pd.read_feather("x.arrow")` 或 `pd.read_feather(io.BytesIO(stdout))` 一行读回。仅支持单 sheet:必须给 `--sheet-id` 或 `--sheet-name`;读整本 workbook 仍走默认 JSON。列类型沿用 typed 读回(string/number/date/bool);`number_format` 以 Arrow Field metadata 保留,Arrow 文件可直接喂回 `+table-put --dataframe`。 |
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -137,20 +154,11 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
|
||||
|
||||
- `annotated_csv` — 含 `[row=N]` 前缀的 CSV 主入口
|
||||
- `col_indices` / `row_indices` — 列字母 / 行号映射数组
|
||||
- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界**,**优先于 `+workbook-info` 的 `row_count`**(`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行)
|
||||
- `has_more` — 是否截断;截断后续读用 `--range` 接着读
|
||||
- `current_region` — 从锚点扩展到被空行空列包围的连续区域的 A1 范围。⚠️ **它不是整表真实边界**:遇表中部整行空行 / 整列空列会截断、可能小于真实数据范围;表尾的汇总 / 签名 / 脚注又可能让它大于纯数据范围。判断整表是否读全须拿 `+workbook-info` 的物理 `row_count` 当上界交叉核对(见上方「`row_count` 与 `current_region` 都不能单独定末行」)
|
||||
- `row_count` / `col_count` — **本次返回的行 / 列数**(= `actual_range` 的尺寸,随 `--range` 变),**不是整表物理总行列数**;整表物理尺寸取 `+workbook-info`
|
||||
- `has_more` — 当前 `--range` 是否因 `--max-chars` 被截断(截断后续读接着用 `--range`);它**只反映本次 range 内是否读完**,`has_more=false` **不代表整表已读全**(range 之外的数据不在判断内)
|
||||
|
||||
**加 `--rows-json`:返回结构化 rows(而非 CSV 字符串)**
|
||||
|
||||
```bash
|
||||
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json
|
||||
```
|
||||
|
||||
`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`):
|
||||
|
||||
- `rows` — 数组,每元素 `{row_number, values}`。`row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key(如 `values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。
|
||||
- `data_not_fully_read` — **仅当没读全时出现**:`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。
|
||||
- 其余字段(`current_region` / `actual_range` / `has_more`)同上。
|
||||
> 要按列类型结构化读出(喂 DataFrame、或 round-trip 回 `+table-put`)用 `+table-get`(见下);`+csv-get` 给的是带 `[row=N]` 前缀的纯值快照,下游需要行号/列坐标时直接从前缀与 `col_indices` 取。
|
||||
|
||||
### `+cells-get`
|
||||
|
||||
@@ -164,6 +172,91 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --she
|
||||
|
||||
> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。
|
||||
|
||||
### `+table-get`(飞书 → DataFrame,类型保真读出)
|
||||
|
||||
`+table-put`(写入侧,见 write-cells reference)的镜像:把表格读回与 `--sheets` 完全同构的 typed 协议(`sheets[]` + `columns:[列名]` + `data:[[行]]` + `dtypes:{列名:pandas_dtype}` + `formats?:{列名:number_format}` + `range`),可直接喂回 `+table-put` 或一行还原 DataFrame。
|
||||
|
||||
**默认(不带 `--range`)读取整张子表的完整 used range**:会跨过表中部的整行空行 / 整列空列,覆盖到真实数据边界。每个子表都回传实际读取的 `range`(如 `A1:F10`)——`+table-get` 不返回分页 / 截断标志,这个 `range` 是判断是否读全的唯一信号:拿它和源 xlsx 行列数、关键末行 / 末日期交叉核对,确认读取完整。仍要精确控制范围时显式传 `--range`。
|
||||
|
||||
列类型从每列 `number_format` 推断(日期格式→`date`/`datetime64[ns]`、数值→`number`/`float64`、bool→`bool`),`date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`(dtypes 输出 `object`),让 `dtypes` 与 `data` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas `astype` 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。
|
||||
|
||||
```bash
|
||||
# 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame)
|
||||
lark-cli sheets +table-get --url "<表URL>"
|
||||
# 可选:--sheet-name / --sheet-id 限定只读某一个子表(不给则读全部)
|
||||
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售"
|
||||
```
|
||||
|
||||
#### 输出 → DataFrame(2 行 helper)
|
||||
|
||||
输出形状对齐 pandas split:`columns` 是列名数组、`data` 是二维数据、`dtypes` 是 `{列名: pandas_dtype_str}` 映射。直接喂给 `pd.DataFrame(...).astype(...)` 就能一次性还原所有列类型(不必逐列 `to_datetime` / `to_numeric`),写入侧 `df_to_sheet` 的镜像 helper:
|
||||
|
||||
```python
|
||||
import pandas as pd
|
||||
def sheet_to_df(sheet):
|
||||
return pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])
|
||||
|
||||
# 单 sheet
|
||||
df = sheet_to_df(out["data"]["sheets"][0])
|
||||
|
||||
# 多 sheet——按名字取
|
||||
sheets = {s["name"]: sheet_to_df(s) for s in out["data"]["sheets"]}
|
||||
df_sales = sheets["销售"]
|
||||
```
|
||||
|
||||
> 显示格式(千分位、百分比、自定义日期)在 `sheet["formats"]`,pandas 不消费;改完数据 round-trip 回去时透传给 `+table-put` 即可,飞书侧显示不变。
|
||||
|
||||
#### `--dataframe-out`(Arrow IPC / Feather v2 二进制读出)
|
||||
|
||||
`--dataframe-out` 是 `+table-put` 入口 `--dataframe` 的镜像:把 typed 读回直接编码成 Arrow IPC 文件,pandas 端一行 `pd.read_feather()` 读回——省掉 JSON 解析 + `astype(dtypes)`,列类型 / `number_format` 走 Arrow schema + Field metadata 保真。**仅支持单 sheet**(Arrow 文件一 schema 容器),必须给 `--sheet-id` 或 `--sheet-name`;读整本 workbook 仍走默认 JSON。
|
||||
|
||||
```bash
|
||||
# 文件
|
||||
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" --dataframe-out @./out.arrow
|
||||
# binary stdout(不落盘)
|
||||
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售" --dataframe-out -
|
||||
```
|
||||
|
||||
```python
|
||||
import io, pandas as pd, subprocess
|
||||
|
||||
# 1) 文件
|
||||
subprocess.run(["lark-cli","sheets","+table-get","--url",URL,
|
||||
"--sheet-name","销售","--dataframe-out","@./out.arrow"], check=True)
|
||||
df = pd.read_feather("./out.arrow")
|
||||
|
||||
# 2) stdin/stdout 管道(不落盘)—— 跟 --dataframe 写入侧对称的一行
|
||||
res = subprocess.run(["lark-cli","sheets","+table-get","--url",URL,
|
||||
"--sheet-name","销售","--dataframe-out","-"],
|
||||
capture_output=True, check=True)
|
||||
df = pd.read_feather(io.BytesIO(res.stdout))
|
||||
```
|
||||
|
||||
> `number_format` 进 Arrow Field metadata(key=`number_format`),Arrow 文件可以直接喂回 `+table-put --dataframe` round-trip 写回,types / formats 一路保真。
|
||||
|
||||
#### round-trip:读 → 改 → 写回(写读对偶)
|
||||
|
||||
`sheet_to_df` 和 write-cells reference 里的 `df_to_sheet` 是一对镜像 helper,round-trip 三段读 / 改 / 写各一行:
|
||||
|
||||
```python
|
||||
import json, subprocess
|
||||
# 1. 读
|
||||
out = json.loads(subprocess.check_output(
|
||||
["lark-cli","sheets","+table-get","--url",URL,"--sheet-name","销售"]))
|
||||
sheet = out["data"]["sheets"][0]
|
||||
df = sheet_to_df(sheet)
|
||||
|
||||
# 2. 改(pandas 操作)
|
||||
df["营收"] = df["营收"] * 1.1
|
||||
|
||||
# 3. 写回(formats 是飞书侧显示格式,pandas 不消费,透传保留显示)
|
||||
payload = {"sheets": [df_to_sheet(df, sheet["name"], formats=sheet.get("formats"))]}
|
||||
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--sheets","-"],
|
||||
input=json.dumps(payload).encode(), check=True)
|
||||
```
|
||||
|
||||
`sheet_to_df(sheet)` 消费 `(columns, data, dtypes)`,`df_to_sheet(df, name, formats=...)` 重新生成同样三个字段——读 / 写完全对偶,只有 `formats` 需要手工透传一次。
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
**常见配置错误(必须注意)**:
|
||||
- **数据源范围要精确**:迷你图的数据源范围必须与实际数据行列精确对应,范围偏移会导致图形展示错误
|
||||
- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过本 Skill 的对象方式创建
|
||||
- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过 `+sparkline-{create|update|delete}` 的对象方式创建
|
||||
- **创建后必须验证**:调用 `+sparkline-list` 确认迷你图配置正确
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
**差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。
|
||||
|
||||
**显式要求边框 / 表头 / 对齐时同样按上面标准落地**(不必等用户说"美化"):① 用户说"给某矩形区域加边框"必须**整个矩形含表头行、数据行、汇总行全部加内外框**,落地后核起 / 末行、末列三边界(反例:要求加边框的区域实际无任何边框);② **新建表头前先确认哪一行才是表头**——别把已有的第一行数据误当表头刷成蓝底白字,真正该加的表头列也要建出来(反例:把第一行数据误设成了表头样式);③ 新增 / 编辑区域的字号必须与原表一致,禁止 13 号与 14 号、10 号与 11 号混杂(反例:新列字号与原表不一致)。
|
||||
|
||||
## 通用样式规范
|
||||
|
||||
> 以下取值标准都在「最高优先级原则」的**继承原表风格 / 扩展而非覆盖**前提下生效:凡涉及"沿用原表"的条目,遵循该原则即可,本节不再逐条复述。
|
||||
@@ -201,4 +203,3 @@ Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+ce
|
||||
- 合并区域样式只写左上角,不要对合并内的其他单元格重复写入样式。
|
||||
|
||||
> 合并单元格完整的安全操作规则(含数据保护、样式占位等 5 条)见 `lark-sheets-range-operations` 的 `+cells-{merge|unmerge}` 章节。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 使用场景
|
||||
|
||||
读写。管理工作簿结构。本 reference 覆盖 11 个 shortcut:
|
||||
读写。管理工作簿结构。本 reference 覆盖 14 个 shortcut:
|
||||
|
||||
| 操作需求 | 使用工具 | 说明 |
|
||||
|---------|---------|------|
|
||||
@@ -41,8 +41,11 @@
|
||||
| `+sheet-hide` | write | 工作簿 |
|
||||
| `+sheet-unhide` | write | 工作簿 |
|
||||
| `+sheet-set-tab-color` | write | 工作簿 |
|
||||
| `+sheet-hide-gridline` | write | 工作簿 |
|
||||
| `+sheet-show-gridline` | write | 工作簿 |
|
||||
| `+workbook-create` | write | 工作簿 |
|
||||
| `+workbook-export` | read | 工作簿 |
|
||||
| `+workbook-import` | write | 工作簿 |
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -59,7 +62,7 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--title` | string | required | 新工作表名称 |
|
||||
| `--index` | int | optional | 插入位置;省略时附加到末尾 |
|
||||
| `--index` | int | optional | 插入位置(0-based);省略时附加到末尾 |
|
||||
| `--row-count` | int | optional | 初始行数(默认 200,上限 50000) |
|
||||
| `--col-count` | int | optional | 初始列数(默认 20,上限 200) |
|
||||
|
||||
@@ -115,6 +118,18 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--color` | string | required | Hex 色值如 `#FF0000`,传空 `""` 清除 |
|
||||
|
||||
### `+sheet-hide-gridline`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
_仅含公共 / 系统 flag。_
|
||||
|
||||
### `+sheet-show-gridline`
|
||||
|
||||
_公共四件套 · 系统:`--dry-run`_
|
||||
|
||||
_仅含公共 / 系统 flag。_
|
||||
|
||||
### `+workbook-create`
|
||||
|
||||
_系统:`--dry-run`_
|
||||
@@ -123,8 +138,10 @@ _系统:`--dry-run`_
|
||||
| --- | --- | --- | --- |
|
||||
| `--title` | string | required | 新 spreadsheet 标题 |
|
||||
| `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 |
|
||||
| `--headers` | string + File + Stdin(简单 JSON) | optional | 表头行 JSON 数组:`["列A","列B"]` |
|
||||
| `--values` | string + File + Stdin(简单 JSON) | optional | 初始数据 JSON 二维数组:`[["alice",95]]` |
|
||||
| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
|
||||
| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常用 `{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。与 --values、--dataframe 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
|
||||
| `--styles` | string + File + Stdin(复合 JSON) | optional | 建表时同时写入的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles`。 |
|
||||
| `--dataframe` | string | optional | 单 sheet 类型保真表格的二进制入口,从一个 Arrow IPC 文件(Feather v2,pandas `df.to_feather()` 直接写出)读入,与 --values / --sheets 互斥。用 `@<path>` 传文件或 `-` 读二进制 stdin(同其他输入 flag 的约定)。Arrow 字节按原样读 —— 不做 TrimSpace / BOM strip,IPC magic 字节完整保留(区别于文本类输入 flag)。列类型从 Arrow schema 推导;每列的 `number_format` 可写在 Arrow Field metadata 里。建表后写入默认子表(`Sheet1` —— 直接复用,不残留空 Sheet1)。要多子表或换落点,请改用 `--sheets`。 |
|
||||
|
||||
### `+workbook-export`
|
||||
|
||||
@@ -136,6 +153,43 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag,与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) |
|
||||
| `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 |
|
||||
|
||||
### `+workbook-import`
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--file` | string | required | 本地文件路径(.xlsx / .xls / .csv) |
|
||||
| `--folder-token` | string | optional | 目标文件夹 token;省略则导入到云空间根目录 |
|
||||
| `--name` | string | optional | 导入后表格名称;省略则用本地文件名(去掉扩展名) |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
|
||||
### `+workbook-create` `--sheets`
|
||||
|
||||
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `name` (string) — 目标子表名
|
||||
- `start_cell` (string?) — 写入起点单元格(A1 记法,如 "B2"),默认 "A1"
|
||||
- `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append]
|
||||
- `header` (boolean?) — 是否写一行列名表头
|
||||
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)
|
||||
- `columns` (array<string>) — 列名字符串数组,顺序与 `data` 中每行取值一一对应
|
||||
- `data` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 `columns` 数
|
||||
- `dtypes` (object?) — 可选
|
||||
- `formats` (object?) — 可选
|
||||
|
||||
### `+workbook-create` `--styles`
|
||||
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。
|
||||
@@ -144,6 +198,119 @@ _公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
|
||||
|
||||
### `+workbook-create`
|
||||
|
||||
新建电子表格,可选预填数据。三种数据入口(untyped `--values` / typed `--sheets` JSON / typed `--dataframe` Arrow 二进制)**三方互斥**,按需选一——三者都走同一条分批写入:
|
||||
|
||||
```bash
|
||||
# 1) untyped:--values(一个二维数组,表头并入第一行;值原样写、类型由飞书自动识别,
|
||||
# 日期会落成文本,配 --styles 控制格式)
|
||||
lark-cli sheets +workbook-create --title "销售" \
|
||||
--values '[["门店","销售额"],["北京",259874]]'
|
||||
|
||||
# 2) typed JSON:--sheets(一步建表 + 类型保真)。date 列落成真日期(可排序/透视)、
|
||||
# number 不丢精度、string 列保前导零(如订单号 00123);多子表一次建。
|
||||
lark-cli sheets +workbook-create --title "交易" --sheets '{
|
||||
"sheets":[
|
||||
{"name":"明细",
|
||||
"columns":["日期","金额","单号"],
|
||||
"dtypes":{"日期":"datetime64[ns]","金额":"float64","单号":"object"},
|
||||
"formats":{"金额":"#,##0.00"},
|
||||
"data":[["2024-01-15",1234.5,"00123"]]}
|
||||
]}'
|
||||
|
||||
# 3) typed binary:--dataframe(pandas df.to_feather 直接出,Arrow IPC / Feather v2)。
|
||||
# 单子表(落点固定为新表的默认子表,原地复用、不残留空 Sheet1),列类型从 Arrow
|
||||
# schema 自动恢复,无需手填 dtypes/formats;要多子表回到 --sheets。
|
||||
lark-cli sheets +workbook-create --title "交易" --dataframe @./in.arrow
|
||||
# 或走 stdin(不落盘):
|
||||
python prepare.py | lark-cli sheets +workbook-create --title "交易" --dataframe -
|
||||
```
|
||||
|
||||
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`);`--dataframe` 是同一份 typed 数据的二进制 wire(Arrow IPC,详见同 reference 的 `+table-put` 段落的 `--dataframe` 小节),按 producer 已有的 API 选——pandas 走 `--dataframe`,多子表 / 手拼 JSON 走 `--sheets`。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令,是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip;pandas 用户也可走 `--dataframe-out` 直拿 Arrow 文件)。
|
||||
|
||||
`--styles` 可在建表写入时同时写视觉处理。它和 `--sheets` 一样只有一种外层写法:顶层对象里放 `styles` 数组;数组每项对应一个子表,含 `name`,并按能力拆成四类可选数组:
|
||||
|
||||
- `cell_styles`:像 `+cells-set-style`,用 A1 单元格 `range` 加扁平样式字段(`font_weight` / `background_color` / `horizontal_alignment` / `vertical_alignment` / `number_format` 等)和可选 `border_styles`;这些样式会随内容在同一次写入里一并应用。完整字段跑 `+workbook-create --print-schema --flag-name styles`。
|
||||
- `cell_merges`:用 A1 单元格 `range` 设置合并,`merge_type` 默认为 `all`,可选 `rows` / `columns`。
|
||||
- `row_sizes`:用行范围(如 `1:3`)设置行高,`type` 为 `pixel` / `standard` / `auto`;`pixel` 需要 `size`。
|
||||
- `col_sizes`:用列范围(如 `A:C`)设置列宽,`type` 为 `pixel` / `standard`;`pixel` 需要 `size`。
|
||||
|
||||
同一单元格命中多个 `cell_styles` 项时,后面的操作继续合并覆盖已传字段。`cell_merges` / `row_sizes` / `col_sizes` 在内容写入后顺序执行。
|
||||
|
||||
```bash
|
||||
# 3) untyped:仍用 {"styles":[...]},只有一个子表样式项(name 忽略);range 覆盖 --values 初始区域
|
||||
lark-cli sheets +workbook-create --title "销售" \
|
||||
--values '[["门店","销售额"],["北京",259874],["上海",198320]]' \
|
||||
--styles '{
|
||||
"styles":[
|
||||
{"name":"Sheet1","cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5","horizontal_alignment":"center","vertical_alignment":"middle"},
|
||||
{"range":"B2:B3","number_format":"#,##0"}
|
||||
]}
|
||||
]
|
||||
}'
|
||||
|
||||
# 4) typed 单子表:--styles.styles[0].name 必须对应 --sheets.sheets[0].name
|
||||
lark-cli sheets +workbook-create --title "交易" --sheets '{
|
||||
"sheets":[
|
||||
{"name":"明细",
|
||||
"columns":["日期","金额"],
|
||||
"dtypes":{"日期":"datetime64[ns]","金额":"float64"},
|
||||
"formats":{"金额":"#,##0.00"},
|
||||
"data":[["2024-01-15",1234.5]]}
|
||||
]}' --styles '{
|
||||
"styles":[
|
||||
{"name":"明细",
|
||||
"cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5",
|
||||
"border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},
|
||||
{"range":"A2:A2","number_format":"yyyy-mm-dd"},
|
||||
{"range":"B2:B2","number_format":"#,##0.00","font_color":"#0f7b0f"}
|
||||
],
|
||||
"cell_merges":[{"range":"A1:B1"}],
|
||||
"col_sizes":[{"range":"A:B","type":"pixel","size":120}],
|
||||
"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}
|
||||
]
|
||||
}'
|
||||
|
||||
# 5) typed 多子表:styles 数组和 sheets 数组长度、顺序、name 都必须一致
|
||||
lark-cli sheets +workbook-create --title "经营看板" --sheets '{
|
||||
"sheets":[
|
||||
{"name":"收入","columns":["月份","收入"],"dtypes":{"收入":"int64"},"formats":{"收入":"#,##0"},"data":[["2026-05",1200000]]},
|
||||
{"name":"成本","columns":["月份","成本"],"dtypes":{"成本":"int64"},"formats":{"成本":"#,##0"},"data":[["2026-05",730000]]}
|
||||
]}' --styles '{
|
||||
"styles":[
|
||||
{"name":"收入","cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#f0f7ff"},
|
||||
{"range":"B2:B2","font_color":"#0f7b0f"}
|
||||
]},
|
||||
{"name":"成本","cell_styles":[
|
||||
{"range":"A1:B1","font_weight":"bold","background_color":"#fff7ed"},
|
||||
{"range":"B2:B2","font_color":"#b42318"}
|
||||
]}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
> ⚠️ **`+workbook-create` 是把内存里的数据写成新表;要把已有的本地 Excel/CSV 文件原样导入成新表,用 `+workbook-import`**(见下),不要先在本地读出文件再 `+workbook-create` 重灌。
|
||||
|
||||
### `+workbook-import`
|
||||
|
||||
把已有的本地 `.xlsx` / `.xls` / `.csv` 文件导入为一个**新的**飞书电子表格(异步任务 + 内置轮询),与 `+workbook-export`(导出)对称,固定导入为电子表格类型。
|
||||
|
||||
```bash
|
||||
# 导入到云空间根目录;表格名默认取本地文件名(去掉扩展名)
|
||||
lark-cli sheets +workbook-import --file ./data.xlsx
|
||||
|
||||
# 指定目标文件夹与导入后表格名
|
||||
lark-cli sheets +workbook-import --file ./report.csv --folder-token <FOLDER_TOKEN> --name "月度报表"
|
||||
```
|
||||
|
||||
- **不接受任何 spreadsheet / sheet 定位 flag**(它是新建,不操作已有表):只有 `--file`(必填)/ `--folder-token` / `--name`。
|
||||
- 本地表格文件 → 飞书电子表格一律用本命令,**不要**用 `drive +import` 导电子表格——它是 sheets 之外的通用导入、还需额外指定 `--type`,绕路且更易错。只有要把本地表格导入成**多维表格**(bitable)时,才改用 `lark-cli drive +import --type bitable`。
|
||||
- 返回 `token` / `url`(导入完成的新表格)/ `ticket` / `ready` / `job_status`;未在内置轮询窗口内完成时返回 `timed_out=true` 与续查命令 `next_command`。
|
||||
|
||||
### `+sheet-create`
|
||||
|
||||
示例:
|
||||
@@ -153,6 +320,8 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
|
||||
--title "汇总" --index 0
|
||||
```
|
||||
|
||||
> 💡 `+sheet-create` 只建一张**空子表**。要在已有工作簿里建子表并一步写入 typed 数据和/或样式,用 `+table-put`(payload 里命名的子表缺则自动新建)配合它的 `--sheets` / `--styles`,省掉先建表再 `+cells-set` / `+cells-set-style` 的二次往返。
|
||||
|
||||
### `+sheet-delete`
|
||||
|
||||
> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。
|
||||
@@ -190,8 +359,16 @@ lark-cli sheets +sheet-unhide --url "..." --sheet-id "$SID"
|
||||
lark-cli sheets +sheet-set-tab-color --url "..." --sheet-id "$SID" --color "#FF0000"
|
||||
```
|
||||
|
||||
### `+sheet-show-gridline` / `+sheet-hide-gridline`
|
||||
|
||||
```bash
|
||||
# 切换子表网格线显隐;二态语义在命令名里,无需额外参数(同 +sheet-hide/+sheet-unhide)
|
||||
lark-cli sheets +sheet-show-gridline --url "..." --sheet-id "$SID"
|
||||
lark-cli sheets +sheet-hide-gridline --url "..." --sheet-id "$SID"
|
||||
```
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`。
|
||||
- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`;`+workbook-create` 的 `--sheets` 与 `--values` **互斥**,给了 `--sheets` 则按 typed 协议校验 payload(其余约束同 `+table-put`)。
|
||||
- `DryRun`:`+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata";`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符,不实际解析为 sheet-id。
|
||||
- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。
|
||||
2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。
|
||||
3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。
|
||||
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条铁律:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
|
||||
|
||||
## 新增列 / 新增行的样式继承(防止视觉风格不一致)
|
||||
|
||||
@@ -13,11 +14,11 @@
|
||||
**完整继承清单**(写新列 / 新行时 cells 数组必须同时携带):
|
||||
|
||||
1. `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字号 / 粗细 / 颜色 / 斜体等)
|
||||
2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment`(H-Align / V-Align)—— 漏继承会导致新列对齐与原列不一致(高频)
|
||||
2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment`(H-Align / V-Align)—— 漏继承会导致新列对齐与原列不一致(常见)
|
||||
3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱
|
||||
4. `cell_styles.background_color`(背景色)
|
||||
5. `border_styles`(四边框)
|
||||
6. **`merged_cells`(合并范围)**——续写场景必查(高频致命错误):用 `+sheet-info --include merges` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致)
|
||||
6. **`merged_cells`(合并范围)**——续写场景必查:用 `+sheet-info --include merges` 读原数据区域的合并信息。**原行有跨列合并**(如标题行 `A1:G1` 合并)时,新行**必须**用 `+cells-{merge|unmerge}` 工具复制相同合并模式到新行(如续写第 3 个周报块的标题行 `A23:G23` 必须合并)。仅传 cells 数组的 5 类样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 工具落地(典型反例:续写多周记录表时,新增周次的标题行未合并,视觉上与原前几周风格不一致)
|
||||
|
||||
**采样模板的正确做法**:
|
||||
- 表头新列 → 读相邻表头单元格(如新加 D1 → 读 A1/B1/C1 任一)
|
||||
@@ -44,13 +45,34 @@
|
||||
|
||||
## 使用场景
|
||||
|
||||
写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text` 中 `type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致,range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。**
|
||||
写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut,按数据来源 + 内容形态选:
|
||||
|
||||
> **单元格图片 vs 浮动图片**:
|
||||
> - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。
|
||||
> - **浮动图片**:图片悬浮在单元格上方,可自由定位和调整大小,不属于单元格内容。→ 使用 lark-sheets-float-image。
|
||||
| 场景 | 用这个 shortcut | 原因 |
|
||||
|------|----------------|------|
|
||||
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
|
||||
| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书,要类型保真(来源不限:DataFrame、Counter、dict、list 都算) | `+table-put` | 列显式声明 `type`:date 落真日期、**金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写。**只要列有数值语义就走这里**,不要在本地把数字拼成带 `$` / `%` 的字符串再走 `+csv-put` |
|
||||
| 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut(公式 `+csv-put` 也能写) |
|
||||
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 |
|
||||
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
|
||||
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
|
||||
|
||||
高频模式(**必须遵守,禁止逐行写入替代**):
|
||||
**优先级**:常规批量写入(纯值或公式)优先 `+csv-put`(最短入参,直接传 CSV 文本);含样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(声明 `number` / `date` 类型 + `number_format`),而不是 `+csv-put`。
|
||||
|
||||
⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果)。**公式内部含逗号 / 引号 / 换行时必须按 RFC 4180 转义**——含逗号的字段整格用双引号包裹、字段内部的引号再翻倍:如 `=COUNTIF(D5:D22,"及格")` 必须写成 `"=COUNTIF(D5:D22,""及格"")"`(外层双引号包裹整格,内部 `"及格"` 的引号翻倍成 `""及格""`)。漏转义会被 CSV 解析器按逗号拆列、整块写入区域错位(如本该 `G4:H6` 错成 `G4:K4`),详见下方 `+csv-put` 示例。**因此含逗号 / 引号 / 换行的公式优先改用 `+cells-set`(JSON 二维数组)/ `+table-put` 结构化写入——直接传 `formula` 字段、零 CSV 转义负担,从根上避免拆列错位。** 此外 `+csv-put` **不会**携带样式/批注/图片,也无法把 `=` 开头的内容当字面量文本写入;需要样式/批注/图片用 `+cells-set`(或"写值 + 补样式"两步法)。
|
||||
|
||||
⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**:金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put` 声明该列 `type:"number"`(百分比存小数,如 `0.305`)+ `format`(如 `"$#,##0.00"` / `"0.0%"` / `"#,##0"`),**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**。
|
||||
|
||||
⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
|
||||
|
||||
## `+cells-set` 写入要点(常用模式 / 公式 / 样式)
|
||||
|
||||
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与铁律;选哪个 shortcut 见上方「使用场景」。
|
||||
|
||||
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas` 的 `--cells`。
|
||||
|
||||
> **单元格图片 vs 浮动图片(最易选错)**:图若**属于某条记录、要随那行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ **单元格图片**(本工具):用 `+cells-set-image`(最短)或 `+cells-set` 的 `rich_text` + `type: "embed-image"`。只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片,见 lark-sheets-float-image。别因「浮动图更好控制 / 更熟」默认选浮动图——它承载"对应某记录"的图会随增删行 / 排序错位。
|
||||
|
||||
常用模式(**必须遵守,禁止逐行写入替代**):
|
||||
|
||||
- 整列公式:先在 `H2` 写一个公式,再用 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"` 向下填充。**禁止对每一行单独调用 `+cells-set` 写入相同结构的公式**
|
||||
- 整列格式:先在 `J1` 写一个带样式的模板单元格,再用 `--copy-to-range "J:J"`
|
||||
@@ -93,24 +115,25 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl
|
||||
2. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / CUBE 系列;注意 `UNIQUE` / `FILTER` / `SPLIT` 飞书是支持的);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环)
|
||||
3. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误
|
||||
4. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER` / `SPLIT`(原生数组函数,详见 `lark-sheets-formula-translation`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count)
|
||||
5. **循环引用预检(高频致命错误)**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己
|
||||
5. **循环引用预检**:写聚合公式(SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己
|
||||
6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支(IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配)
|
||||
7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规
|
||||
8. **量纲 / 单位换算 / 数量乘项预检(公式不报错但结果整体偏倍数)**:从文本提取数字做计算前,先核对**单位是否统一、是否漏乘数量、口径是否一致**——这类错误公式能跑通、无 `#` 报错,回读也看不出(值"像对的")。必须用本地脚本对 3–5 个代表行**离线手算一遍预期值**,与公式结果逐格比对量级:① 单位不一致先统一再算(典型反例:尺寸 `320CM*337CM` 直接取数相乘除以 1e6 得 0.11,正确是 CM→MM 换算后得 10.78,**差 100 倍**);② 按"单件×数量"的量必须乘数量列(典型反例:侧面板面积漏乘 F 列数量,F=2 的行只算了一半);③ 标准值口径对齐(典型反例:营养成分 mg/kg 与 g/100g 口径混用,整列放大 100 倍)。**口径 / 单位 / 数量任一项错,整列计算结果就是错的;这类错误公式不报错、回读也不易看出,必须靠离线手算对照。**
|
||||
|
||||
⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 的下标取值飞书不支持(SPLIT 本身支持,取第 N 项用 `INDEX(SPLIT(...),N)`);`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须:
|
||||
⚠️ **收到 `formula_errors` 反馈后不要只打补丁**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 的下标取值飞书不支持(SPLIT 本身支持,取第 N 项用 `INDEX(SPLIT(...),N)`);`non_formula` 是 `=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须:
|
||||
|
||||
1. **重新审视整条公式的完整性**:被 formula_errors 标出的那一行,公式除了下标语法错,还可能有其他先天缺陷(字符清洗不全、IFERROR 兜底漏条件、引用列写错),修完语法错后立即整体复核
|
||||
2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列必须把同样的清洗/兜底逻辑同步到所有相似列**,禁止出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错"
|
||||
3. **修完再读回验证**:不只看 `formula_errors` 为空(这只证明编译通过,不证明运行时有值),必须 `+csv-get` 读目标列前 3-5 行,确认**非空源数据对应的目标列有非空计算结果**
|
||||
4. **核心心智**:`formula_errors` 是"帮你暴露编译错"的工具,不是"修掉它就收工"的通行证。编译通过 + 运行时 IFERROR 兜底空 = 用户视角的"没算出来"
|
||||
|
||||
⚠️ **新增行的边框/样式禁止用 `{}` 跳过(高频致命错误)**:`cells` 数组里 `{}` 的语义是"**此单元格不做任何修改、保留原状态**"。这在写入**已有行**时是安全的(原有边框/样式保持不变),但在写入**新行**(比如表尾追加汇总行、扩展行)时是灾难:新行底子里本来就没边框,`{}` 不修改 = 保留无边框状态,导致该 cell 视觉断裂。
|
||||
⚠️ **新增行的边框/样式禁止用 `{}` 跳过**:`cells` 数组里 `{}` 的语义是"**此单元格不做任何修改、保留原状态**"。这在写入**已有行**时是安全的(原有边框/样式保持不变),但在写入**新行**(比如表尾追加汇总行、扩展行)时是灾难:新行底子里本来就没边框,`{}` 不修改 = 保留无边框状态,导致该 cell 视觉断裂。
|
||||
|
||||
⚠️ **"汇总行"识别 → 读 `lark-sheets-visual-standards` 拿完整样式规范**:下述双重条件**同时满足**才是汇总行,禁止仅凭"有 AVERAGE"就判定:
|
||||
- **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合"
|
||||
- **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列
|
||||
|
||||
满足上述时,**不要在本 skill 里猜样式**,直接去读 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。
|
||||
满足上述时,**不要在本文里猜样式**,直接去读 `lark-sheets-visual-standards` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。
|
||||
|
||||
反例(**不是**汇总行,禁止自动加粗):
|
||||
- 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算
|
||||
@@ -208,24 +231,6 @@ lark-cli sheets +dropdown-set \
|
||||
|
||||
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
|
||||
|
||||
## 工具选择
|
||||
|
||||
本 skill 提供以下 CLI shortcut,按数据来源 + 内容形态选:
|
||||
|
||||
| 场景 | 用这个 shortcut | 原因 |
|
||||
|------|----------------|------|
|
||||
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
|
||||
| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut |
|
||||
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 |
|
||||
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
|
||||
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
|
||||
|
||||
**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`。
|
||||
|
||||
⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。
|
||||
|
||||
⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | Risk | 分组 |
|
||||
@@ -235,6 +240,7 @@ lark-cli sheets +dropdown-set \
|
||||
| `+cells-set-image` | write | 单元格 |
|
||||
| `+dropdown-set` | write | 对象 |
|
||||
| `+csv-put` | write | 单元格 |
|
||||
| `+table-put` | write | 单元格 |
|
||||
|
||||
## Flags
|
||||
|
||||
@@ -299,10 +305,20 @@ _公共四件套 · 系统:`--dry-run`_
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--start-cell` | string | required | 目标区域起点 A1(如 `A1`、`B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet);必须是单个单元格,不接受范围写法;终点按 CSV 实际行列数自动推断 |
|
||||
| `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 |
|
||||
| `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;可写值或公式(以 = 开头的单元格按公式计算);不带样式 / 批注 / 图片,需要这些用 +cells-set。 |
|
||||
| `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 |
|
||||
| `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17)时自动取其左上角单元格(隐藏 flag:不在 `--help` 列出,但可正常传入) |
|
||||
|
||||
### `+table-put`
|
||||
|
||||
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
|
||||
|
||||
| Flag | Type | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--sheets` | string + File + Stdin(复合 JSON) | xor | Typed 表格协议(pandas-DataFrame-shaped)JSON,与 `--dataframe` 互斥:顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常用 `{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。`dtypes` 值是 pandas dtype 字符串(`int64`、`float64`、`Int64`、`bool`、`boolean`、`datetime64[ns]`、`object`、...),CLI 端映射成内部 string/number/date/bool —— 省略 `dtypes` 时该列按文本写入(适合原始 CSV-shaped 数据)。`formats[col]` 是 Excel number_format 字符串(如 `#,##0.00`、`0.0%`、`yyyy-mm`);缺省时 date 列用 `yyyy-mm-dd`,string 列用文本格式 `@`。 |
|
||||
| `--dataframe` | string | xor | 单 sheet 类型保真表格的二进制入口,从一个 Arrow IPC 文件(即 Feather v2,pandas `df.to_feather()` 直接写出)读入,与 `--sheets` 互斥。用 `@<path>` 传文件或 `-` 读二进制 stdin(同其他输入 flag 的约定)。Arrow 字节按原样读 —— 不做 TrimSpace / BOM strip,IPC magic 字节完整保留(区别于文本类输入 flag)。列类型从 Arrow schema 推导(int*/uint*/float* → number,date32/date64/timestamp → date,utf8/large_utf8 → string,bool → bool);每列的 `number_format` 可写在 Arrow Field metadata 里(`pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})`)。子表走默认落点:名为 `Sheet1`(缺则新建),从 A1 起覆盖写并带表头。要换子表名 / 起始位置 / 写入方式,或要写多子表,请改用 `--sheets`。 |
|
||||
| `--styles` | string + File + Stdin(复合 JSON) | optional | 类型保真写入后再应用的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个被写入的子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。styles 数组的长度/顺序/name 必须与被写入的子表对应:配 --sheets 时与 --sheets.sheets 对应;配 --dataframe(单子表,名为 Sheet1)时只给一个 name 为 `Sheet1` 的 styles 项。完整 cell_styles 字段结构跑 `+table-put --print-schema --flag-name styles`。 |
|
||||
|
||||
## Schemas
|
||||
|
||||
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
|
||||
@@ -338,20 +354,45 @@ _列表选项_
|
||||
**数组项**(类型 string):
|
||||
- 标量:string
|
||||
|
||||
### `+table-put` `--sheets`
|
||||
|
||||
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `name` (string) — 目标子表名
|
||||
- `start_cell` (string?) — 写入起点单元格(A1 记法,如 "B2"),默认 "A1"
|
||||
- `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append]
|
||||
- `header` (boolean?) — 是否写一行列名表头
|
||||
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)
|
||||
- `columns` (array<string>) — 列名字符串数组,顺序与 `data` 中每行取值一一对应
|
||||
- `data` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 `columns` 数
|
||||
- `dtypes` (object?) — 可选
|
||||
- `formats` (object?) — 可选
|
||||
|
||||
### `+table-put` `--styles`
|
||||
|
||||
|
||||
**数组项**(类型 object):
|
||||
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
|
||||
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
|
||||
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
- `name` (string) — 子表名
|
||||
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size each: { range: string, size?: number, type: enum }
|
||||
|
||||
## Examples
|
||||
|
||||
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
|
||||
|
||||
### `+cells-set` 的拆分与转介绍
|
||||
|
||||
"工具选择"段已讲清纯值(`+csv-put`)vs 富写入(`+cells-set`)。下表补 CLI 侧的 `+cells-set` **兄弟拆分**,以及不属于本 skill 的**跨 skill 转介绍**——避免 agent 用 `+cells-set` 硬扛所有写入场景。
|
||||
"工具选择"段已讲清纯值(`+csv-put`)vs 富写入(`+cells-set`)。下表补 CLI 侧的 `+cells-set` **兄弟拆分**,以及不属于本 reference 的**跨 reference 转介绍**——避免 agent 用 `+cells-set` 硬扛所有写入场景。
|
||||
|
||||
| 写入场景 | 用这个 | 不要用 |
|
||||
|---------|--------|--------|
|
||||
| 只改**已有 cell 的样式**,不动 value/formula | `+cells-set-style` | `+cells-set`(会触发不必要的值写入) |
|
||||
| 把**单张图片嵌入**到某个 cell | `+cells-set-image` | `+cells-set`(参数更繁琐) |
|
||||
| **插行/列 + 写入** 这种多步组合,且要原子 | `+batch-update`(跨 skill) | 多次独立 `+cells-set`(非原子;插入会扰动后续 range) |
|
||||
| 在**多个不连续 range** 上应用同一组样式 | `+cells-batch-set-style`(跨 skill) | 多次 `+cells-set-style`(非原子) |
|
||||
| **插行/列 + 写入** 这种多步组合,且要原子 | `+batch-update`(见 lark-sheets-batch-update) | 多次独立 `+cells-set`(非原子;插入会扰动后续 range) |
|
||||
| 在**多个不连续 range** 上应用同一组样式 | `+cells-batch-set-style`(见 lark-sheets-batch-update) | 多次 `+cells-set-style`(非原子) |
|
||||
|
||||
### `+cells-set`
|
||||
|
||||
@@ -413,15 +454,35 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
|
||||
--start-cell "A1" --csv @data.csv
|
||||
```
|
||||
|
||||
> `+csv-put` 比 `+cells-set` 短得多——只想批量灌纯值时优先用它。需要公式/样式才换 `+cells-set`。
|
||||
> `+csv-put` 比 `+cells-set` 短得多——批量灌值或公式时优先用它。需要样式/批注/图片才换 `+cells-set`。
|
||||
>
|
||||
> ⚠️ `=` 开头的字符串会被当字面量写入(**不会变公式**):
|
||||
> ✅ `=` 开头的单元格会被当作公式计算(不是字面量文本):
|
||||
>
|
||||
> ```bash
|
||||
> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \
|
||||
> --start-cell "A1" \
|
||||
> --csv $'name,score\nalice,=SUM(B2:B10)'
|
||||
> # ↑ A2 实际写入字符串 "=SUM(B2:B10)",**不是公式**。需要写公式请用 +cells-set。
|
||||
> # ↑ B2 写入公式 =SUM(B2:B10),读回 formula 保留、value 为计算结果。
|
||||
> # 反过来:无法用 +csv-put 写「= 开头的字面量文本」(会被当公式);样式/批注/图片仍用 +cells-set。
|
||||
> ```
|
||||
>
|
||||
> ⚠️ **公式内部含逗号 / 引号必须 RFC 4180 转义**:CSV 用逗号分隔字段,公式里的逗号(如 `COUNTIF(D5:D22,"及格")` 的参数分隔逗号)会被解析器当成字段分隔符,把一格拆成多格、整块二维结构压扁错位。规则:**含逗号的字段整格用双引号包裹,字段内部的引号再翻倍**:
|
||||
>
|
||||
> ```bash
|
||||
> # 从 G4 写一个 2 列 3 行的统计块;=COUNTIF 含逗号 + 内部引号,必须转义
|
||||
> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \
|
||||
> --start-cell "G4" \
|
||||
> --csv $'统计项,结果\n成绩总和,=SUM(C5:C22)\n及格人数,"=COUNTIF(D5:D22,""及格"")"'
|
||||
> # ↑ "=COUNTIF(D5:D22,""及格"")":外层双引号包裹整格,内部 "及格" 的引号翻倍成 ""及格""。
|
||||
> # 裸写 =COUNTIF(D5:D22,"及格") 会被 CSV 按逗号拆成两格、写入区域从 G4:H6 错位成 G4:K4。
|
||||
> ```
|
||||
>
|
||||
> 💡 **含逗号 / 引号 / 换行的公式优先用 `+cells-set`(JSON 二维数组)或 `+table-put` 写入**——结构化写入直接传 `formula` 字段,没有 CSV 转义负担,从根上杜绝拆列错位。准备给 `+csv-put` 的公式加逗号时,先考虑换结构化写入:
|
||||
>
|
||||
> ```bash
|
||||
> # 同样的统计块,结构化写入无需任何转义
|
||||
> lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "G4:H6" \
|
||||
> --cells '[[{"value":"统计项"},{"value":"结果"}],[{"value":"成绩总和"},{"formula":"=SUM(C5:C22)"}],[{"value":"及格人数"},{"formula":"=COUNTIF(D5:D22,\"及格\")"}]]'
|
||||
> ```
|
||||
|
||||
> **定位 + 写入边界(关键,避免误覆盖)**:
|
||||
@@ -430,8 +491,116 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
|
||||
> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`):**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。
|
||||
> - 要保护非空 cell:`--allow-overwrite=false`(落区内出现非空 cell 即报错)。
|
||||
|
||||
### `+table-put`(DataFrame → 飞书,类型保真写入)
|
||||
|
||||
把结构化数据(DataFrame、list of dict、Counter)类型保真写入**已有**表(写入语义同 `+cells-set`)。协议形状**对齐 pandas `to_json(orient="split")`**:`columns:[列名]` + `data:[[行...]]`,可选 `dtypes:{列名:pandas_dtype}` 决定每列类型(number 保精度、date 落真日期),可选 `formats:{列名:number_format}` 覆盖显示格式(千分位 / 百分比 / 自定义日期)。dtypes 缺失时整张表按 string 写入(带 `@` 文本格式,邮编 / 订单号等含前导零的 id 保真)。
|
||||
|
||||
只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同协议、一步建表 + 类型保真写入,详见 workbook reference)。读回用镜像命令 `+table-get`(见 read-data reference),输出与 `--sheets` 同构、可 round-trip。
|
||||
|
||||
```bash
|
||||
# sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet
|
||||
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
|
||||
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
|
||||
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
|
||||
```
|
||||
|
||||
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。
|
||||
|
||||
#### DataFrame → 协议(5 行 helper)
|
||||
|
||||
pandas 的 `df.to_json(orient="split", date_format="iso")` 一步完成所有清洗(NaN→null、Timestamp→ISO 字符串、numpy 标量→原生数字),helper 只要把 dtypes 拼上去——5 行覆盖单 / 多 sheet:
|
||||
|
||||
```python
|
||||
import json
|
||||
def df_to_sheet(df, name, formats=None):
|
||||
return {"name": name,
|
||||
**json.loads(df.to_json(orient="split", date_format="iso")),
|
||||
"dtypes": df.dtypes.astype(str).to_dict(),
|
||||
**({"formats": formats} if formats else {})}
|
||||
|
||||
# 单 sheet(显式 format 覆盖默认显示)
|
||||
payload = {"sheets": [df_to_sheet(df, "销售", {"营收": "#,##0.00", "毛利率": "0.0%"})]}
|
||||
|
||||
# 多 sheet——helper 让每个 sheet 一行,不再重复 boilerplate
|
||||
payload = {"sheets": [df_to_sheet(df1, "销售"),
|
||||
df_to_sheet(df2, "成本"),
|
||||
df_to_sheet(df3, "利润")]}
|
||||
```
|
||||
|
||||
> **CSV-shaped 全文本数据**(不需要类型保真、含前导零的 id 也要保留)省掉 dtypes 即可,inline 一行写完,不必走 helper(注意保留 `date_format="iso"`,否则 datetime 列会被序列化成 epoch 毫秒数字,CLI 拒绝):
|
||||
> ```python
|
||||
> payload = {"sheets": [{"name": "原始",
|
||||
> **json.loads(df.to_json(orient="split", date_format="iso"))}]}
|
||||
> ```
|
||||
> **别把 `to_json + json.loads` 换成 `df.to_dict(orient="split")`**:会留 `numpy.int64` 让 `json.dumps` 后续报 "not serializable"——这一步是清洗的关键。
|
||||
|
||||
不用 pandas 也行——typed 协议就是纯 JSON。手写场景:
|
||||
|
||||
```python
|
||||
# Counter / dict / 手拼数据:直接写 columns + data,按需加 dtypes/formats
|
||||
payload = {"sheets": [{
|
||||
"name": "渠道",
|
||||
"columns": ["channel", "count", "rate"],
|
||||
"data": [["app", 1240, 0.62], ["web", 760, 0.38]],
|
||||
"dtypes": {"count": "int64", "rate": "float64"},
|
||||
"formats": {"rate": "0.0%"},
|
||||
}]}
|
||||
```
|
||||
|
||||
> **dtype 速查**:`int64`/`float64`(数值)、`Int64`(含空值的整数,nullable)、`bool`/`boolean`、`datetime64[ns]`(date,默认 `yyyy-mm-dd`)、`object`(string)。pandas dtype 字符串原样塞进 dtypes 即可,CLI 端按前缀匹配(`int*`/`uint*`/`Int*`/`float*` → number 等)。未识别 dtype 兜底为 string。
|
||||
|
||||
#### `--dataframe`(Arrow IPC / Feather v2 二进制入口)
|
||||
|
||||
`--dataframe` 与 `--sheets` 互斥、功能等价,但走二进制 wire——pandas `df.to_feather()` 写出的 Arrow IPC 文件直接喂 CLI,类型从 Arrow schema 自动恢复,**不用再手填 dtypes/formats**,也自动绕过 NaT / NaN / `datetime64[ns, tz]` 的 JSON 序列化坑。子表落点固定为 `Sheet1`、A1 起覆盖写、带表头;要换子表名 / 起始位置 / 多子表,回到 `--sheets` JSON 协议。
|
||||
|
||||
```bash
|
||||
# 文件(cwd 相对路径;受 SafePath 沙箱约束,不接受绝对路径)
|
||||
lark-cli sheets +table-put --url "<表URL>" --dataframe @./in.arrow
|
||||
# stdin 二进制(不落盘)
|
||||
python prepare.py | lark-cli sheets +table-put --url "<表URL>" --dataframe -
|
||||
```
|
||||
|
||||
```python
|
||||
import io, subprocess, pandas as pd
|
||||
df = pd.DataFrame({"date": pd.to_datetime(["2024-01-15"]), "amount": [1234.5], "id": ["00123"]})
|
||||
|
||||
# 1) 文件
|
||||
df.to_feather("./in.arrow") # 写到当前目录
|
||||
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--dataframe","@./in.arrow"], check=True)
|
||||
|
||||
# 2) stdin(不落盘)—— pandas 写 BytesIO,subprocess 把 buf 灌进去
|
||||
buf = io.BytesIO(); df.to_feather(buf)
|
||||
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--dataframe","-"],
|
||||
input=buf.getvalue(), check=True)
|
||||
```
|
||||
|
||||
> 每列的 `number_format` 写在 Arrow Field metadata 里,CLI 端自动透传到飞书显示格式(千分位 / 百分比 / 自定义日期等):
|
||||
> ```python
|
||||
> import pyarrow as pa, pyarrow.feather as feather
|
||||
> table = pa.Table.from_pandas(df)
|
||||
> schema = table.schema.set(
|
||||
> table.schema.get_field_index("amount"),
|
||||
> pa.field("amount", pa.float64(), metadata={b"number_format": b"#,##0.00"}))
|
||||
> feather.write_feather(table.cast(schema), "./in.arrow")
|
||||
> ```
|
||||
|
||||
#### `--styles`(写入时同时套样式)
|
||||
|
||||
`--styles` 在 typed 写入后顺带应用视觉处理,省掉一次 `+cells-set-style` 往返。协议与 `+workbook-create --styles` **完全同构**(详见 workbook reference):顶层 `{styles:[...]}`,数组每项对应一个被写入的子表、含 `name`,并按能力拆成四类可选数组——`cell_styles`(A1 单元格 range + 扁平样式字段,含 `number_format` / 颜色 / 对齐 / `border_styles`,随内容在同一次写入里一并应用)、`cell_merges`、`row_sizes`、`col_sizes`。styles 数组的长度 / 顺序 / name 必须与被写入的子表对应:配 `--sheets` 时与 `--sheets.sheets` 对齐;配 `--dataframe`(单子表,名为 `Sheet1`)时只给一个 name 为 `Sheet1` 的 styles 项。
|
||||
|
||||
```bash
|
||||
lark-cli sheets +table-put --url "<表URL>" \
|
||||
--sheets '{"sheets":[{"name":"明细","columns":["日期","金额"],"dtypes":{"日期":"datetime64[ns]","金额":"float64"},"formats":{"金额":"#,##0.00"},"data":[["2024-01-15",1234.5]]}]}' \
|
||||
--styles '{"styles":[{"name":"明细",
|
||||
"cell_styles":[{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5","horizontal_alignment":"center"}],
|
||||
"cell_merges":[{"range":"A1:B1"}],
|
||||
"col_sizes":[{"range":"A:B","type":"pixel","size":120}]}]}'
|
||||
```
|
||||
|
||||
完整字段跑 `+table-put --print-schema --flag-name styles`。
|
||||
|
||||
### Validate / DryRun / Execute 约束
|
||||
|
||||
- `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。
|
||||
- `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;`+table-put` 给了 `--styles` 则按子表名 / 顺序 / 数量与 `--sheets`(或 `--dataframe` 的单子表 `Sheet1`)对齐校验;防爆参数上限校验。
|
||||
- `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。
|
||||
- `Execute`:写后不自动回读;如需确认,自行调用 `+cells-get --range <写入区域> --include value,formula` 抽样核对。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:本地 `.pptx` / `.pdf` 导入为 slides(走 `lark-drive` 的 `drive +import --type slides`)、云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -14,33 +14,39 @@ metadata:
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 用户提供 PDF/PPTX/slides 材料并要求生成或改写 PPT | 必须先导入/回读材料,并以导入后的 presentation 作为目标底稿二次创作;即使材料“只作为模板/视觉线索”,也要在导入后的 presentation 内替换内容模块 | `drive +import --type slides`、`planning-layer.md`、`asset-planning.md` |
|
||||
| 新建 PPT | 仅在没有用户提供可导入材料、用户明确要求另建,或导入失败/回读失败时,先解析并盘点用户附件,规划 `slide_plan.json`,再按复杂度创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式但没有提供本地/在线模板材料 | 先检索内置模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录;规划层、素材处理层、视觉规划和验证分别遵循 [planning-layer.md](references/planning-layer.md)、[asset-planning.md](references/asset-planning.md)、[visual-planning.md](references/visual-planning.md)、[validation-checklist.md](references/validation-checklist.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
创建前自检或失败排障时,按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
使用内置模板生成或改写页面时,先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。如果用户已经提供本地/在线模板材料,优先按用户材料导入二创,不走内置模板检索。
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
@@ -75,8 +81,7 @@ lark-cli auth login --domain slides
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
|
||||
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
|
||||
@@ -86,6 +91,8 @@ lark-cli auth login --domain slides
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
@@ -124,9 +131,7 @@ lark-cli auth login --domain slides
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 无用户导入材料时的创建方式
|
||||
|
||||
以下创建方式仅适用于没有用户提供可导入材料、用户明确要求另建 deck,或导入失败/`xml_presentations.get` 无法回读的异常场景。
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
@@ -142,7 +147,7 @@ lark-cli auth login --domain slides
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
模板细则见 [template-catalog.md](references/template-catalog.md)。仅在用户没有提供本地/在线模板材料时使用内置模板流程:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
@@ -152,19 +157,18 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;没有用户提供模板材料时,模板需求按“模板与脚本优先流程”处理
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
- 如果用户提供附件、文件路径、素材目录或类似“附件文件路径:...”的文本,先按 asset-planning.md 解析路径、枚举文件、读取/导入/上传可用素材;不要直接跳到大纲或 XML
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用用户附件、导入后的 slides 或模板材料,标明每类素材如何参与二次创作
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 如果 plan 有 `target_xml_presentation_id`,默认在该 presentation 内创建、替换或删除页面;只有无导入材料、用户明确要求另建,或导入失败/回读失败时,才按“无用户导入材料时的创建方式”新建 deck
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
@@ -174,7 +178,7 @@ Step 4: 审查 & 交付
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
无用户导入材料的新建 PPT 可用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
@@ -262,7 +266,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
@@ -275,14 +278,13 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **用户材料默认作为二创底稿**:用户提供 PDF/PPTX/slides 材料并要求生成、改写、二创、压缩页数或保留材料风格/资产时,必须先导入或回读为 slides;默认 `target_xml_presentation_id` 等于导入或已有材料的 `xml_presentation_id`,在该 presentation 内继续创作。“只作为模板/视觉线索”只表示不复制原文案,不表示可以跳过导入或新建脱离材料的 deck
|
||||
2. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
3. **创建流程**:仅在没有用户提供可导入材料、用户明确要求新建 deck,或导入失败/`xml_presentations.get` 无法回读时,才使用 `slides +create` 新建目标 deck;页数多、内容不可用、只参考风格、布局复杂或 PDF 是正文资料都不是新建理由
|
||||
4. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
5. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
6. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
7. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
8. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
9. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,208 +1,28 @@
|
||||
# Material And Asset Planning
|
||||
# Asset Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,planning layer 必须先激活本素材处理层,再写入或更新 `slide_plan.json`。目标是让 agent 先利用用户已经提供的本地素材和上下文,再决定是否需要内置模板、联网搜索或 XML-native 兜底。
|
||||
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
|
||||
|
||||
本文件覆盖两类对象:
|
||||
|
||||
- `material_inventory`:deck 级素材盘点,记录本地素材、链接、缺口、用途和搜索策略。
|
||||
- `asset_need`:page 级视觉资产需求,记录每页是否需要图片、图表、截图、图标、文案来源或兜底视觉。
|
||||
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- 本地素材优先。用户给了文件、目录、截图、PDF、PPTX、文案、数据表、链接或已有 slides 时,先按 Attachment Resolution 解析并盘点用途;禁止忽略附件直接生成大纲或 XML。
|
||||
- 没有合适本地素材时,才考虑联网搜索、内置模板、IconPark 或 XML-native 兜底。用户已提供模板材料时,这些能力只能补缺,不能替代导入后的目标 presentation 或主视觉系统。
|
||||
- 素材进入 plan 前必须分类并写清用途;不要把“有文件”直接等同于“应出现在页面上”。
|
||||
- 页面不能依赖不可获得素材才能完成。每个 `asset_need` 都必须有 `fallback_if_missing`。
|
||||
- 真实图片进入 slides 前必须走支持的上传路径:`slides +media-upload` 或 `+create --slides` 的 `@./path` 占位符。禁止把 http(s) 外链直接写进 `<img src>`。
|
||||
- `.pptx` / `.pdf` / online slides 参与制作或改写 PPT 时,按 Local Material Handling 先判断 `rewrite_source` / `copy_source`;可作为视觉底稿的材料必须导入或回读为 slides。
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
|
||||
|
||||
## Attachment Resolution
|
||||
## JSON Shape
|
||||
|
||||
在写 `slide_plan.json` 前,先把用户提供的附件文本转成可操作的本地路径清单。
|
||||
|
||||
1. 从 prompt 提取所有附件线索:`附件文件路径:...`、`文件:...`、`上传文件:...`、相对路径、绝对路径、目录路径,以及结构化输入中单独列出的文件名。
|
||||
2. 逐个解析路径:
|
||||
- 已是绝对路径:直接检查是否存在。
|
||||
- 相对路径:先按当前工作目录解析;如果用户或调用方另行说明了素材根目录,也要按该根目录解析同一相对路径。
|
||||
- 只有文件名:先查用户明确给出的素材目录;没有目录时,只在当前工作目录和任务上下文明确给出的附件目录中查找。
|
||||
- 包含 URL 或在线 slides/wiki/drive/doc 链接:按对应 skill/API 获取内容或 token;不要当成本地路径。
|
||||
3. 如果路径文本和真实文件名只有轻微差异(例如 `-` 与 `_`、`.` 与 `_`、URL 转义、空格、中文括号、图片平台后缀中的 `~` 被本地保存为 `_`),在同目录用文件名相似匹配找候选;只有唯一高置信候选时使用,并在 `material_inventory.inputs[].notes` 记录映射。
|
||||
4. 对目录路径必须先列出直接子文件并按扩展名分组;不要只记录目录本身。
|
||||
5. 找不到的附件写入 `material_inventory.missing`,并说明会用什么 XML-native 或内置能力兜底。找不到附件不能成为空白页的理由。
|
||||
|
||||
最小盘点动作:
|
||||
|
||||
- 文档/表格/图片/PPT/PDF 至少要记录 `source`、`resolved_path`、`kind`、`usage`、`status`。
|
||||
- 如果素材会进入页面,`asset_need.candidate_sources` 必须使用解析后的可用路径,而不是原始不可用的路径文本。
|
||||
- 如果素材只用于理解或提供视觉线索,也必须在 `material_inventory.inputs` 中出现,避免后续 XML 生成阶段遗忘。
|
||||
|
||||
## Material Roles
|
||||
|
||||
将每个输入素材归入以下角色之一;一个素材可以有多个角色,但要分别说明用途。
|
||||
|
||||
| Role | 用途 | 常见来源 | 默认处理 |
|
||||
|------|------|----------|----------|
|
||||
| `background_reference` | 补充主题背景、事实、约束、术语、受众信息 | 文档、网页、PRD、报告、会议纪要 | 读取/摘要后影响叙事和页面重点 |
|
||||
| `visual_asset` | 可直接进入页面的视觉素材 | 图片、截图、logo、图表、论文 figure | 上传后放入计划区域;不合适则重绘或兜底 |
|
||||
| `copy_source` | 文案、标题、大纲、讲稿、卖点、结论来源 | Markdown、TXT、Docx、用户 prompt、会议纪要 | 改写成低密度 slide 文案 |
|
||||
| `data_source` | 生成表格、指标卡、图表的数据来源 | CSV、XLSX、表格截图、结构化数据 | 转成 chart/table/数字卡 |
|
||||
| `brand_asset` | 品牌识别和视觉约束 | logo、VI 色板、品牌手册 | 影响 `theme_style` / `visual_system` |
|
||||
| `rewrite_source` | 导入后承载二次创作的目标底稿 | 用户已有 PPTX/PDF/slides、背景模板、旧版汇报、待美化稿 | 导入/回读后作为 target presentation,规划保留、替换、删除和重排 |
|
||||
|
||||
PDF/PPTX/slides 的主角色只能是 `rewrite_source` 或 `copy_source`。如需表达背景、视觉或品牌信息,只写进 `usage`,不能改变目标 presentation。
|
||||
|
||||
## Source Priority
|
||||
|
||||
1. 用户显式提供的本地素材。
|
||||
2. 当前工作区内与任务明确相关的素材。
|
||||
3. 用户给出的在线 slides/wiki/drive/doc 链接。
|
||||
4. 内置模板库和 IconPark。
|
||||
5. 联网搜索公开素材或背景信息。
|
||||
6. XML-native 兜底视觉。
|
||||
|
||||
如果多个来源冲突,用户显式提供的素材优先;无法判断时,在 plan 的 `open_issues` 里记录需要用户确认。
|
||||
|
||||
## Local Material Handling
|
||||
|
||||
- `.pptx` / `.pdf` / online slides 若承担模板、背景模板、旧稿、待美化稿、品牌视觉或页面结构角色,默认是 `rewrite_source`:先导入或回读为 slides,`target_xml_presentation_id` 默认等于导入/已有 presentation,并在同一个 presentation 内创建、替换、删除或重排页面。
|
||||
- “内容不可用”“只作为背景模板”“不要使用模板文字”“只参考风格”只表示该材料不是 `copy_source`;它仍默认是 `rewrite_source`,用于保留或重绘背景、版式、图片资产和页面结构。
|
||||
- `.pdf` 若只是论文、报告、PRD、教案正文等内容资料,可以作为 `copy_source` 读取/摘要;但 `copy_source` 不得替代已有 `rewrite_source`,也不得成为新建 deck 的理由。
|
||||
- 用户明确说“只参考风格”时,不新增单独角色;仍把 PDF/PPTX/slides 作为 `rewrite_source` 导入/回读,只在 `usage` 中说明“不复制模板文案,仅沿用或重绘风格、版式和页面结构”。
|
||||
- 已有 online slides:直接回读 XML,默认把它作为 `rewrite_source`;不要再走导入。
|
||||
- `.png` / `.jpg` / `.jpeg` 等图片:判断是可直接展示、需要裁切/缩放、还是只用于理解。进入 XML 前必须上传或使用 `@./path` 占位符。
|
||||
- `.md` / `.txt` / 文档类内容:作为 `copy_source` 或 `background_reference`,提炼为低密度页面文案,不要整段搬进 slide。
|
||||
- `.docx` / `.doc`:通常是 `copy_source` 和 `background_reference`。先读取或转换提取正文、标题层级、表格和内嵌图片线索,再改写为 slide 叙事。用户要求“不要杜撰数据”时,数值和图表只能来自这类源文件或表格源;缺数据则在 plan 中标注缺口。
|
||||
- `.xlsx` / `.xls` / `.csv`:通常是 `data_source`。先识别工作表、列名、时间范围、指标和关键数值,再规划 `<chart>` / 表格 / 数字卡。用户明确要求精准图表时,必须让图表数据来自表格,不要手工编造。
|
||||
|
||||
## PDF Template Pre-slicing
|
||||
|
||||
大 PDF 模板用于二次创作时,可以先切出关键模板页生成小 PDF,再用 `drive +import --type slides` 导入小 PDF。这样减少导入和回读成本,同时仍保留在导入后的 presentation 内二创的主路径。
|
||||
|
||||
仅在同时满足这些条件时才切割:
|
||||
|
||||
- 任务是制作、改写、二创、压缩页数或替换内容模块,而不是单纯导入 PDF。
|
||||
- PDF 是 `rewrite_source`,只需要其中的视觉骨架、背景、版式、图片资产或页面结构。
|
||||
- 已能明确选择关键页,例如封面、目录、章节页、图谱/流程页、表格页、结尾页,通常保留 6-10 页或用户指定页。
|
||||
|
||||
禁止切割的场景:
|
||||
|
||||
- 用户只要求“导入 PDF 为 slides”“转换格式”“保留完整 PDF”“检查导入效果”。
|
||||
- 用户要求保留全部页序、全部页面内容或逐页迁移。
|
||||
- 无法判断关键页,且切割会丢失用户可能需要的视觉结构。
|
||||
|
||||
切割后在 `material_inventory.inputs[]` 中记录原 PDF 和压缩 PDF 的关系:
|
||||
|
||||
- 原 PDF 仍记录为 `source`,`usage` 说明只取关键视觉页。
|
||||
- 压缩 PDF 记录为实际导入对象,`status: "preprocessed"` 或 `"imported"`。
|
||||
- 记录 `selected_pages`、`preprocessed_path`、选择理由,以及后续导入得到的 `imported_xml_presentation_id` / `target_xml_presentation_id`。
|
||||
- 生成新页并回读成功后,再按计划删除或替换导入模板中的旧内容页。
|
||||
|
||||
## Image Asset Migration
|
||||
|
||||
导入的 PPTX/PDF 页面里可能已有 `<img src="file_token">`、背景图或品牌图。这些图片 token 可以在同一个导入后的 presentation 内复用;只有跨到另一个新建 presentation 时,才不能直接复制旧 XML 里的 `<img src>`。
|
||||
|
||||
在 `slide_plan.json` 中为模板或原稿记录 `template_asset_strategy`:
|
||||
|
||||
- `preserve_imported_page`:模板页含需要复用的背景图、装饰图、品牌图或复杂图片版式。优先在导入页上用 `+replace-slide` / `block_insert` / `block_replace` 做局部编辑,保留现有 `<img>` token。
|
||||
- `rebuild_in_imported_presentation`:需要重做 XML-native 页面,但仍在同一个导入后的 presentation 内创建/替换页面。可以复用该 presentation 内已有图片 token,无需下载再上传;删除旧页前先确认新页已创建成功。
|
||||
- `mixed`:同一 deck 中部分页面保留导入页图片资产,部分页面重建。每页在 plan 里说明来源页、目标 presentation、是否复用同一 presentation 的图片 token、是否需要重新上传。
|
||||
|
||||
首次运行时先判断目标页是否仍在导入后的同一个 presentation 内。只要同一 presentation,就可以复用回读 XML 里的图片 token;不要静默复制图片 token 到新文稿。异常新建只能由用户明确要求另建,或导入失败/回读失败触发;若需要复用导入页图片,必须记录下载/重新上传方案。
|
||||
|
||||
如果选择 `preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed`,且导入后的 slides 会作为最终交付物继续编辑,完成后必须把在线文件标题改成用户任务对应的新标题,避免仍保留导入时的模板/原附件名称。标题修改走 `lark-drive` 的 `drive files patch`,使用 `new_title` 字段;不要为了改标题重建整份 PPT。
|
||||
|
||||
## Imported Material As Draft
|
||||
|
||||
当用户提供 PDF/PPTX/slides 材料并要求制作或改写 PPT 时,先在 plan 中定两类来源:
|
||||
|
||||
- `rewrite_source`:导入/回读后的目标视觉底稿,记录 `imported_xml_presentation_id`、`target_xml_presentation_id`、`template_asset_strategy` 和 `target_title`;默认策略是 `preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed`。
|
||||
- `copy_source`:真正提供文案、事实、标题层级、数据或案例的材料。
|
||||
|
||||
只有用户明确要求新建,或导入失败/`xml_presentations.get` 无法回读时,才允许新建 deck,并说明原因和图片资产迁移策略。“页数不超过 N 页”、内容不可用、只参考风格、PDF 是正文资料或布局复杂,都不是新建 deck 的理由。如果附件里有素材但 plan 没有使用,必须在 `material_inventory.inputs[].usage` 说明原因。
|
||||
|
||||
## Search Policy
|
||||
|
||||
联网搜索不是默认动作。只有出现以下情况才搜索:
|
||||
|
||||
- 用户要求查找公开事实、行业背景、竞品、logo、截图、图片或最新资料。
|
||||
- plan 中存在关键视觉缺口,且用户模板材料无法满足;搜索结果只能补缺,不能替代用户模板的主视觉系统。
|
||||
- 用户给出的主题需要真实世界背景才能避免空泛表达。
|
||||
|
||||
搜索结果使用规则:
|
||||
|
||||
- 图片素材必须先落到本地,再按 Core Rules 的上传路径进入 XML。
|
||||
- 背景信息必须转化为 `background_reference` 摘要和页面结论,不要把长文本塞进页面。
|
||||
- 无法确认版权或来源可靠性时,只作为风格/信息参考,不直接作为可展示图片。
|
||||
- 如果搜索失败或不适合使用,按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
|
||||
## Plan Shape
|
||||
|
||||
Deck 级 `material_inventory` 片段示例:
|
||||
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
|
||||
|
||||
```json
|
||||
{
|
||||
"material_inventory": {
|
||||
"local_first": true,
|
||||
"inputs": [
|
||||
{
|
||||
"source": "./template.pdf",
|
||||
"resolved_path": "./template.pdf",
|
||||
"kind": "rewrite_source",
|
||||
"usage": "Import key visual pages as slides and use as the target visual draft; do not copy placeholder text.",
|
||||
"preprocessed_path": "./template_key_pages.pdf",
|
||||
"selected_pages": [1, 2, 5, 8, 12, 20],
|
||||
"status": "imported",
|
||||
"imported_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"target_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"template_asset_strategy": "rebuild_in_imported_presentation",
|
||||
"target_title": "New deck title"
|
||||
},
|
||||
{
|
||||
"source": "./notes.md",
|
||||
"resolved_path": "./notes.md",
|
||||
"kind": "copy_source",
|
||||
"usage": "Condense into slide headlines and speaker intent.",
|
||||
"status": "available"
|
||||
},
|
||||
{
|
||||
"source": "./draft.pptx",
|
||||
"resolved_path": "./draft.pptx",
|
||||
"kind": "rewrite_source",
|
||||
"usage": "Import and read back XML; preserve visual structure and restyle each page.",
|
||||
"status": "available"
|
||||
}
|
||||
],
|
||||
"missing": [
|
||||
{
|
||||
"need": "Product screenshot for workflow page",
|
||||
"search_policy": "Use local screenshot if provided; otherwise search only if user wants real UI imagery.",
|
||||
"fallback_if_missing": "Draw a simplified UI wireframe with labeled panels."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a slide derived from a `rewrite_source`, add source-page mapping inside that slide plan, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_page": 3,
|
||||
"source_operation": "restyle",
|
||||
"preserve_content": "Keep the original claim, metrics, and section role; improve layout hierarchy and redraw the chart."
|
||||
}
|
||||
```
|
||||
|
||||
Page 级 `asset_need` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "screenshot",
|
||||
"source_preference": "local_first",
|
||||
"purpose": "Show the target workflow state instead of describing it with bullets.",
|
||||
"candidate_sources": ["./assets/workflow.png"],
|
||||
"suggested_query": "product workflow screenshot",
|
||||
"fallback_if_missing": "Draw a simplified UI wireframe with three panels and callout labels."
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
|
||||
"suggested_query": "agent native slides runtime architecture diagram",
|
||||
"fallback_if_missing": "Draw grouped boxes connected by arrows with short labels."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -211,9 +31,7 @@ For a page without a meaningful asset need, use:
|
||||
```json
|
||||
{
|
||||
"asset_type": "none",
|
||||
"source_preference": "none",
|
||||
"purpose": "No external or simulated asset needed; the page is text-led.",
|
||||
"candidate_sources": [],
|
||||
"suggested_query": "",
|
||||
"fallback_if_missing": "Use typography, spacing, and simple accent shapes only."
|
||||
}
|
||||
@@ -225,7 +43,7 @@ For a page without a meaningful asset need, use:
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter; map those to `<whiteboard>` at generation time.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
@@ -235,23 +53,23 @@ Do not invent new asset types unless the user asks for a special visual format.
|
||||
|
||||
## Planning Guidance
|
||||
|
||||
Match source type to slide role. Detailed geometry belongs in `visual-planning.md`; this mapping only helps choose `asset_need`.
|
||||
Match asset type to slide role:
|
||||
|
||||
- `architecture-diagram` layout usually pairs with `architecture_diagram` or `flow_diagram`.
|
||||
- `process-flow` layout usually pairs with `flow_diagram`, `icon`, or `infographic`.
|
||||
- `comparison` layout often works with `icon`, `chart`, or `infographic`.
|
||||
- `timeline` layout often works with `icon`, `chart`, or shape-based milestone markers.
|
||||
- `big-number` layout often works with `chart`, `data_source`, or `infographic`.
|
||||
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, `infographic`, or a layout derived from imported material.
|
||||
- `big-number` layout often works with `chart` or `infographic`, but only if it supports the metric.
|
||||
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, or `infographic`; if missing, use a large placeholder diagram or stylized panel.
|
||||
|
||||
`suggested_query` is a future lookup hint. Execute the search only when the search policy says remote material is needed and local sources are insufficient.
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Use a bordered UI wireframe with product area labels, not an empty image."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
|
||||
@@ -260,14 +78,47 @@ Weak fallbacks to avoid:
|
||||
- "Leave blank if unavailable."
|
||||
- "Use generic decoration."
|
||||
|
||||
## Examples
|
||||
|
||||
Transformer Self-Attention page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "paper_figure",
|
||||
"purpose": "Explain token-to-token attention and why each output token mixes context.",
|
||||
"suggested_query": "Transformer self attention attention matrix diagram",
|
||||
"fallback_if_missing": "Draw a simplified attention matrix with token labels, colored weights, and arrows from input tokens to one highlighted output token."
|
||||
}
|
||||
```
|
||||
|
||||
System architecture page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show the runtime path from user prompt to plan, XML generation, Slides API creation, and fetch verification.",
|
||||
"suggested_query": "slides generation runtime architecture planner XML API verification",
|
||||
"fallback_if_missing": "Draw four grouped boxes connected left-to-right with arrows; put verification as a return arrow from Slides API to agent."
|
||||
}
|
||||
```
|
||||
|
||||
Business comparison page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "infographic",
|
||||
"purpose": "Make before/after differences scannable without dense bullet lists.",
|
||||
"suggested_query": "before after product workflow comparison infographic",
|
||||
"fallback_if_missing": "Use two side-by-side panels with matching icon circles and three parallel rows of concise labels."
|
||||
}
|
||||
```
|
||||
|
||||
## Plan To XML Contract
|
||||
|
||||
When generating XML:
|
||||
|
||||
1. Apply `material_inventory` first: imported target material, copy sources, data sources, and visual assets decide what each page can use.
|
||||
2. If `target_xml_presentation_id` points to imported user material, create, replace, reorder, or delete pages in that presentation; do not call `slides +create` for a detached new deck unless the plan records an explicit user request to create a new deck or an import/readback failure.
|
||||
3. If a real visual asset exists and the workflow supports it, place it in the planned visual region.
|
||||
4. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
5. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
6. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
7. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 编辑已有 PPT:读-改-写闭环
|
||||
|
||||
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
|
||||
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。
|
||||
|
||||
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` |
|
||||
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
|
||||
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 |
|
||||
| 多页版式重建、整页坐标重排 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old,不生成新 Slides 链接 |
|
||||
|
||||
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -137,7 +136,6 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
|
||||
## 相关文档
|
||||
|
||||
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
|
||||
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
|
||||
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`)
|
||||
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
|
||||
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# slides +replace-pages(多页整页重建)
|
||||
|
||||
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
|
||||
|
||||
> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages \
|
||||
--as user \
|
||||
--presentation <slides_url_or_xml_presentation_id> \
|
||||
--pages @pages.json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
|
||||
| `--pages` | 是 | JSON 数组,每项包含 `slide_id` 和 `content`;支持 literal、`@file`、stdin `-` |
|
||||
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
|
||||
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
|
||||
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
|
||||
|
||||
## pages.json
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slide_id": "slide_short_id_1",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_short_id_2",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 每项必须提供 `slide_id`;不支持 `slide_number`。
|
||||
- `content` 必须是完整 `<slide>...</slide>` XML。
|
||||
- 同一批次不能重复 `slide_id`。
|
||||
- CLI 不会回读整份 presentation;如果 `slide_id` 已失效,create/delete 阶段会返回对应错误。
|
||||
|
||||
## Dry Run
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages --as user \
|
||||
--presentation "$PID" \
|
||||
--pages @pages.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
输出包含 `xml_presentation_id`、`pages_count`、`plan`,以及每页的 `old_slide_id`、`insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
|
||||
|
||||
## 成功输出
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "xxx",
|
||||
"pages_count": 2,
|
||||
"status": "completed",
|
||||
"summary": {
|
||||
"replaced": 2,
|
||||
"failed": 0,
|
||||
"total": 2
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"old_slide_id": "old3",
|
||||
"new_slide_id": "new3",
|
||||
"status": "replaced"
|
||||
}
|
||||
],
|
||||
"revision_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
如果使用 `--continue-on-error` 且任一页面失败,CLI 会继续处理后续页,但最终以 partial failure 非零退出;stdout 仍保留完整 `results`,顶层 `ok` 为 `false`,`status` 为 `partial_failure`。
|
||||
|
||||
`status` 可能为:
|
||||
|
||||
- `replaced`:新页创建成功,旧页删除成功。
|
||||
- `create_failed`:新页创建失败,旧页保留。
|
||||
- `delete_failed`:新页已创建,但旧页删除失败。
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。
|
||||
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
|
||||
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。
|
||||
@@ -1,94 +0,0 @@
|
||||
# slides +screenshot
|
||||
|
||||
## 用途
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation '<xml_presentation_id 或 slides/wiki URL>' \
|
||||
--slide-number 1
|
||||
```
|
||||
|
||||
渲染本地 XML 内容:
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--content @slide.xml
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
|
||||
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入 |
|
||||
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
|
||||
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
|
||||
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
|
||||
| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
|
||||
|
||||
## 示例
|
||||
|
||||
### 单页截图
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-number 1
|
||||
```
|
||||
|
||||
### 多页截图
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation slides_example_presentation_id \
|
||||
--slide-number 1 \
|
||||
--slide-number 2 \
|
||||
--output-dir .lark-slides/screenshots/demo
|
||||
```
|
||||
|
||||
### 渲染 XML 预览
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--content @.lark-slides/out/demo/slide.xml \
|
||||
--output-name preview
|
||||
```
|
||||
|
||||
## 返回值
|
||||
|
||||
返回 JSON 不包含 Base64 图片内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"output_dir": ".lark-slides/screenshots",
|
||||
"screenshots": [
|
||||
{
|
||||
"slide_id": "slide_example_id",
|
||||
"slide_number": 1,
|
||||
"format": "png",
|
||||
"path": "/abs/path/.lark-slides/screenshots/slides_example_presentation_id_p001_slide_example_id.png",
|
||||
"size": 12345
|
||||
}
|
||||
]
|
||||
},
|
||||
"msg": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 优先使用 `slides +screenshot` 保存本地图片,不要把图片 Base64 打到 stdout。
|
||||
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。
|
||||
3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
|
||||
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
|
||||
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 用途
|
||||
|
||||
按 `slide_id` 或 1-based `slide_number` 拉取指定演示文稿单页的 XML 内容(可指定历史版本)。常用于"读-改-写"编辑闭环的第一步。
|
||||
按 `slide_id` 拉取指定演示文稿单页的 XML 内容(可指定历史版本)。常用于"读-改-写"编辑闭环的第一步。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -22,7 +22,6 @@ lark-cli slides xml_presentation.slide get --as user --params '<json_params>'
|
||||
{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id",
|
||||
"slide_number": 1,
|
||||
"revision_id": -1
|
||||
}
|
||||
```
|
||||
@@ -30,13 +29,12 @@ lark-cli slides xml_presentation.slide get --as user --params '<json_params>'
|
||||
| 字段 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `xml_presentation_id` | string | 是 | 目标演示文稿唯一标识 |
|
||||
| `slide_id` | string | 否 | 目标页面唯一标识;与 `slide_number` 同时传时优先使用 `slide_id` |
|
||||
| `slide_number` | integer | 否 | 目标页码,从 1 开始;未传 `slide_id` 时可用 |
|
||||
| `slide_id` | string | 是 | 目标页面唯一标识 |
|
||||
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版(默认)|
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 按 slide_id 读最新版本
|
||||
### 读最新版本
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
@@ -45,15 +43,6 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
}'
|
||||
```
|
||||
|
||||
### 按页码读取
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_number": 2
|
||||
}'
|
||||
```
|
||||
|
||||
### 只提取 XML 内容
|
||||
|
||||
```bash
|
||||
@@ -98,15 +87,14 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 404 | 演示文稿或页面不存在 | 检查 `xml_presentation_id` / `slide_id` / `slide_number` |
|
||||
| 404 | 演示文稿或页面不存在 | 检查 `xml_presentation_id` / `slide_id` |
|
||||
| 403 | 权限不足 | 需要 `slides:presentation:read` scope,并对该 PPT 有访问权限 |
|
||||
| 400 | `revision_id` 不存在 | 传了无效版本号,用 `-1` 或真实存在的版本号 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **执行前必做**:`lark-cli schema slides.xml_presentation.slide.get` 查看最新参数结构
|
||||
2. **选择器优先级**:`slide_id` 和 `slide_number` 至少传一个;如果同时传,CLI 会以 `slide_id` 为准。
|
||||
3. **block_id 提取**:返回 XML 里每个顶层块(shape、img、table、chart、whiteboard 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id:
|
||||
2. **block_id 提取**:返回 XML 里每个顶层块(shape、img、table、chart、whiteboard 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide get --as user \
|
||||
|
||||
@@ -21,8 +21,7 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": -1,
|
||||
"remove_attr_id": false
|
||||
"revision_id": -1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -30,7 +29,6 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
|------|------|------|------|
|
||||
| `xml_presentation_id` | string | 是 | 演示文稿的唯一标识符 |
|
||||
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版本 |
|
||||
| `remove_attr_id` | boolean | 否 | 是否移除返回 XML 中的 `id` 属性;默认 `false` |
|
||||
|
||||
## 使用示例
|
||||
|
||||
@@ -46,15 +44,6 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
### 移除 XML id 属性读取
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"remove_attr_id": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 保存到文件
|
||||
|
||||
```bash
|
||||
@@ -99,9 +88,8 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
|
||||
|
||||
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
|
||||
2. 返回的 XML 在 `data.xml_presentation.content` 字段中
|
||||
3. `remove_attr_id=true` 会移除返回 XML 中的元素 `id` 属性,适合在重复 id 导致全文读取失败时兜底;但返回内容无法直接用于依赖 `block_id` 的精确替换、插入、评论定位等操作
|
||||
4. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
5. 建议将获取的 XML 保存为文件,便于后续编辑或备份
|
||||
3. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
4. 建议将获取的 XML 保存为文件,便于后续编辑或备份
|
||||
|
||||
## 相关命令
|
||||
|
||||
|
||||
@@ -1,106 +1,77 @@
|
||||
# 规划层
|
||||
# Planning Layer
|
||||
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是演示文稿的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新演示文稿、替换整页结构,仍然需要规划层。
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构,仍然需要规划层。
|
||||
|
||||
## 必需流程
|
||||
## Required Flow
|
||||
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
|
||||
2. 激活素材处理层:按 `asset-planning.md` 解析提示词中的附件路径、素材目录、上传文件名和链接,盘点用户提供的本地素材、可引用链接和缺口素材;本地素材优先,没有合适本地素材时再使用内置模板或联网搜索。
|
||||
3. 选择唯一规划目录:`.lark-slides/plan/<deck-or-task-id>/`。
|
||||
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
|
||||
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`。
|
||||
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`。
|
||||
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
6. 读取 `xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
|
||||
7. 按规划文件、视觉规划和素材规划规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
8. 创建或大幅改写后,按 `validation-checklist.md` 做显式验证;本文件只要求验证记录能说明规划到 XML 的对应关系。
|
||||
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
素材和模板不能代替规划文件,但素材处理层可以决定材料角色、目标 presentation、改写方式和资产复用策略;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
|
||||
可导入为 slides 的用户材料(`.pptx`、`.pdf` 或已有 online slides)参与制作/改写 PPT 时,默认是 `rewrite_source`:先导入或回读为 slides,`target_xml_presentation_id` 默认等于导入/已有 presentation,并在该 presentation 内继续创作。
|
||||
## Plan Path
|
||||
|
||||
“内容不可用”“只作为背景模板”“不要使用模板文字”“只参考风格”只排除 `copy_source`,不排除 `rewrite_source`。这类材料仍要导入/回读,并在导入后的 presentation 内替换、插入或删除页面内容。只有用户明确要求另建,或导入失败/`xml_presentations.get` 无法回读时,才新建演示文稿,并在 plan 中写明原因。
|
||||
Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.
|
||||
|
||||
如果大 PDF 模板只用于二次创作的视觉底稿,可先按 `asset-planning.md` 选择关键模板页生成压缩版 PDF,再导入这个小 PDF。纯导入、归档、格式转换或用户要求保留完整 PDF 页序的任务,不做切割。
|
||||
Recommended IDs:
|
||||
|
||||
不要把附件路径只当作提示词文本。像 `附件文件路径:path/to/report.docx` 这样的路径必须解析到真实文件;相对路径按当前工作目录和用户明确给出的素材根目录解析,不能硬编码某个固定附件目录。
|
||||
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
|
||||
- Existing PPT rewrite: the `xml_presentation_id`.
|
||||
- Ambiguous or untitled task: short task slug plus date/time.
|
||||
|
||||
## 规划路径
|
||||
Rules:
|
||||
|
||||
每个演示文稿或任务使用独立的规划目录,避免同一工作区内多个演示文稿相互覆盖。
|
||||
- Do not reuse `.lark-slides/plan/slide_plan.json` as a shared path.
|
||||
- Create the directory before writing the file.
|
||||
- Reuse the same plan path for XML generation and post-create verification for that deck.
|
||||
|
||||
推荐 ID:
|
||||
## Artifact Lifecycle
|
||||
|
||||
- 新建演示文稿:标题短标识加日期时间,例如 `q3-review-20260507-1805`。
|
||||
- 改写已有 PPT:使用 `xml_presentation_id`。
|
||||
- 主题不明确或未命名任务:短任务标识加日期时间。
|
||||
`.lark-slides/` is local agent state. It supports recovery, iteration, and later edits, but it should not be treated as source code or committed by default.
|
||||
|
||||
规则:
|
||||
Keep:
|
||||
|
||||
- 不要复用 `.lark-slides/plan/slide_plan.json` 作为共享路径。
|
||||
- 写文件前先创建目录。
|
||||
- 同一个演示文稿的 XML 生成和创建后验证必须复用同一个规划路径。
|
||||
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
|
||||
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
|
||||
|
||||
## 产物生命周期
|
||||
Clean or avoid keeping:
|
||||
|
||||
`.lark-slides/` 是本地智能体状态,用于恢复、迭代和后续编辑;默认不要把它当作源码提交。
|
||||
- Transient XML payloads after successful creation and verification. Prefer `/tmp` for throwaway XML, or delete generated XML files after success.
|
||||
- Stale XML drafts that no longer match the current presentation state.
|
||||
|
||||
保留:
|
||||
Exception:
|
||||
|
||||
- 创建成功或完成大幅改写后,保留 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。规划文件是该演示文稿后续可编辑的设计状态。
|
||||
- 对后续工作有帮助时,保留小型清单,例如 `xml_presentation_id`、页面 ID、`revision_id`、规划路径和验证状态。
|
||||
- If creation fails or partially succeeds, keep the relevant XML/debug payloads until recovery is complete. Record `xml_presentation_id` first, then fetch current state before retrying.
|
||||
|
||||
清理或避免保留:
|
||||
|
||||
- 创建和验证成功后的临时 XML 请求内容。一次性 XML 优先放在 `/tmp`,或成功后删除生成的 XML 文件。
|
||||
- 已不匹配当前演示文稿状态的陈旧 XML 草稿。
|
||||
|
||||
例外:
|
||||
|
||||
- 如果创建失败或部分成功,保留相关 XML 或调试请求内容,直到恢复完成。先记录 `xml_presentation_id`,再拉取当前状态后重试。
|
||||
|
||||
## JSON 结构
|
||||
## JSON Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"presentation_goal": "说明方案并争取下一阶段批准。",
|
||||
"audience": "了解领域背景、但需要简洁决策叙事的产品和工程负责人。",
|
||||
"theme_style": "清爽商务风格,浅色背景,克制蓝色强调,视觉层级清晰。",
|
||||
"material_inventory": {
|
||||
"local_first": true,
|
||||
"inputs": [
|
||||
{
|
||||
"source": "./reference.pdf",
|
||||
"resolved_path": "./reference.pdf",
|
||||
"kind": "rewrite_source",
|
||||
"usage": "导入为在线幻灯片后,在导入 presentation 内二次创作。",
|
||||
"status": "imported",
|
||||
"imported_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"target_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"template_asset_strategy": "rebuild_in_imported_presentation"
|
||||
}
|
||||
],
|
||||
"missing": [
|
||||
{
|
||||
"need": "产品界面截图",
|
||||
"search_policy": "仅在没有本地截图时搜索;否则使用 XML 原生兜底视觉。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
|
||||
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
|
||||
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
|
||||
"visual_system": {
|
||||
"background_strategy": "内容页使用统一浅色基底;封面和结尾页可使用同一强调色体系下的深色处理。",
|
||||
"motif": "可复用的左侧强调条,以及一致的卡片和页眉处理。",
|
||||
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
|
||||
"motif": "A reusable left accent bar and consistent card/header treatments.",
|
||||
"color_roles": {
|
||||
"primary": "用于主要结构母题,占约 60-70% 的视觉权重。",
|
||||
"secondary": "用于分组区域、对比面板或辅助类别。",
|
||||
"accent": "仅用于关键数字、结论或焦点标记。"
|
||||
"primary": "Used for the dominant structural motif and about 60-70% of visual weight.",
|
||||
"secondary": "Used for grouped regions, comparison panels, or supporting categories.",
|
||||
"accent": "Used only for key numbers, conclusions, or focus markers."
|
||||
}
|
||||
},
|
||||
"typography_constraints": {
|
||||
"title_max_lines": 2,
|
||||
"body_max_lines_per_box": 2,
|
||||
"footer_max_lines": 1,
|
||||
"long_text_handling": "先缩短、拆分到多个文本框,或把细节移到演讲者备注;不要靠极小字号硬塞。"
|
||||
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
|
||||
},
|
||||
"verification_plan": {
|
||||
"check_background_consistency": true,
|
||||
@@ -111,50 +82,49 @@
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"title": "方案标题",
|
||||
"key_message": "该方向已经具备小范围试点条件。",
|
||||
"title": "Proposal Title",
|
||||
"key_message": "The initiative is ready for a focused pilot.",
|
||||
"layout_type": "title-cover",
|
||||
"visual_focus": "大标题区域配一条简洁支撑语。",
|
||||
"visual_focus": "Large title area with one concise supporting statement.",
|
||||
"asset_need": {
|
||||
"asset_type": "logo",
|
||||
"purpose": "在开场页传达产品或团队身份。",
|
||||
"suggested_query": "产品标志",
|
||||
"fallback_if_missing": "使用小型文字徽标和抽象形状母题替代真实标志。"
|
||||
"purpose": "Signal product or team identity on the opening page.",
|
||||
"suggested_query": "product logo",
|
||||
"fallback_if_missing": "Use a small text badge and abstract shape motif instead of a real logo."
|
||||
},
|
||||
"text_density": "low",
|
||||
"speaker_intent": "界定本次决策问题,并建立整份演示文稿的观点。"
|
||||
"speaker_intent": "Frame the decision and establish the deck's point of view."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 必需字段
|
||||
## Required Fields
|
||||
|
||||
顶层字段:
|
||||
Top-level fields:
|
||||
|
||||
- `presentation_goal`:整份演示文稿要达成的目标。
|
||||
- `audience`:目标读者或听众,以及他们默认具备的背景。
|
||||
- `theme_style`:视觉语气、配色方向和专业风格。
|
||||
- `material_inventory`:规划层对本地输入、链接参考、选定用途、缺失素材、搜索策略和兜底方案的记录。遵循 `asset-planning.md`。
|
||||
- `visual_system`:演示文稿级视觉规则,必须跨页稳定,包括背景策略、复用母题和颜色角色。
|
||||
- `typography_constraints`:演示文稿级文字行数、文本框密度和长文本处理限制,用于约束 XML 生成前的文案。
|
||||
- `verification_plan`:最终验证记录必须覆盖的规划专属检查项。创建后的详细验证见 `validation-checklist.md`。
|
||||
- `slides`:有序页面计划。
|
||||
- `presentation_goal`: what the whole deck is trying to achieve.
|
||||
- `audience`: target readers or listeners and their assumed background.
|
||||
- `theme_style`: visual tone, palette direction, and professional style.
|
||||
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
|
||||
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
|
||||
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
|
||||
- `slides`: ordered page plans.
|
||||
|
||||
每一页必须包含:
|
||||
Each slide must include:
|
||||
|
||||
- `page`:从 1 开始的页码。
|
||||
- `title`:页面标题。
|
||||
- `key_message`:本页必须传达的单一核心观点。
|
||||
- `layout_type`:计划使用的页面结构。
|
||||
- `visual_focus`:主导视觉对象或区域。
|
||||
- `asset_need`:仅用于规划的结构化素材元数据;不要求立即搜索、下载或上传。遵循 `asset-planning.md`。
|
||||
- `text_density`:`low`、`medium` 或 `high`。
|
||||
- `speaker_intent`:演讲者为什么需要这一页,以及它如何推进叙事。
|
||||
- `page`: 1-based page number.
|
||||
- `title`: slide title.
|
||||
- `key_message`: the one idea this page must land.
|
||||
- `layout_type`: planned page structure.
|
||||
- `visual_focus`: dominant visual object or region.
|
||||
- `asset_need`: planning-only structured asset metadata; no search, download, or upload required. Follow `asset-planning.md`.
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
## 布局词表
|
||||
## Layout Vocabulary
|
||||
|
||||
除非用户明确需要自定义结构,否则使用以下 `layout_type` 值之一:
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
|
||||
- `title-cover`
|
||||
- `section-divider`
|
||||
@@ -169,101 +139,81 @@
|
||||
- `quote-highlight`
|
||||
- `conclusion`
|
||||
|
||||
该值必须影响 XML 几何结构,不能只是标签。例如,`timeline` 应生成横向或纵向序列,`comparison` 应生成清晰并排区域,`big-number` 应为大指标保留主导空间。
|
||||
The value must affect XML geometry, not just appear as a label. For example, `timeline` should create a horizontal or vertical sequence, `comparison` should create distinct side-by-side regions, and `big-number` should reserve dominant space for a large metric.
|
||||
|
||||
## 文字密度规则
|
||||
## Text Density Rules
|
||||
|
||||
- `low`:标题加 1 条短陈述,或 1-3 个很短的标签。
|
||||
- `medium`:标题加 2-4 条简洁要点,或 2-4 个带标签区域。
|
||||
- `high`:仅在用户确实需要细节时使用;优先用表格、分栏或分组区域,不要使用长项目符号列表。
|
||||
- `low`: title plus 1 short statement, or 1-3 very short labels.
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: allowed only when the user needs detail; use tables, columns, or grouped regions instead of a long bullet list.
|
||||
|
||||
不要让所有页面都变成“标题 + 项目符号”。对于 4 页及以上的演示文稿,在内容允许时尽量使用至少 4 种不同的 `layout_type`。
|
||||
Do not let all pages become title + bullet slides. For decks of 4 or more pages, aim for at least 4 different `layout_type` values when the content allows it.
|
||||
|
||||
文字密度必须匹配计划中的几何结构。如果页面需要长标题、双语标签、论文图注、法律声明或密集技术表述,必须记录如何缩短、拆分或移到演讲者备注。不要依赖小字号或紧凑文本框来塞下内容。
|
||||
Text density must be realistic for the planned geometry. If a page needs long titles, bilingual labels, paper figure captions, legal disclaimers, or dense technical wording, record how the text will be shortened, split, or moved to speaker notes. Do not rely on small font sizes or tight boxes to make text fit.
|
||||
|
||||
## 视觉系统规划
|
||||
## Visual System Planning
|
||||
|
||||
生成 XML 前,定义能贯穿整份演示文稿的视觉系统:
|
||||
Before generating XML, define a visual system that can survive the whole deck:
|
||||
|
||||
- `background_strategy`:说明普通内容页的默认背景,以及哪些页面角色可以有意不同。不要让页面使用接近但不一致的背景色。
|
||||
- `motif`:选择一到两个可复用结构装置,例如侧边栏、页眉轨道、编号节点、卡片处理、图示泳道或章节色带。母题要足够一致,让页面看起来属于同一套设计。
|
||||
- `color_roles`:分配主色、辅助色和强调色角色。同一种颜色不能在不同页面表达无关含义。
|
||||
- `cover_content_relationship`:如果封面使用不同的深色或图片主导处理,说明它如何通过共享颜色、母题或几何关系连接内容页。
|
||||
- `closing_relationship`:如果结尾页呼应封面,明确写出,避免看起来像临时换了主题。
|
||||
- `background_strategy`: specify the default background for normal content pages, and which page roles may intentionally differ. Do not let pages drift through near-identical but inconsistent background colors.
|
||||
- `motif`: choose one or two reusable structural devices, such as a side bar, header rail, numbered node, card treatment, diagram lane, or section band. The motif should appear consistently enough that pages feel related.
|
||||
- `color_roles`: assign primary, secondary, and accent roles. The same color must not mean unrelated things across pages.
|
||||
- `cover_content_relationship`: if the cover uses a different dark or image-led treatment, state how it connects to content pages through shared colors, motifs, or geometry.
|
||||
- `closing_relationship`: if the closing page mirrors the cover, state that explicitly so it looks intentional rather than like a new theme.
|
||||
|
||||
这些是规划约束,不是装饰备注。它们必须影响生成 XML 中的坐标、背景填充、形状样式和文本位置。
|
||||
These are planning constraints, not decoration notes. They must affect coordinates, background fills, shape styles, and text placement in generated XML.
|
||||
|
||||
## 迭代状态
|
||||
## Iterative Deck State
|
||||
|
||||
继续编辑已有演示文稿时,更新同一个规划路径,不要创建脱节的新规划文件。规划文件必须和已经实际创建的内容保持一致。
|
||||
When continuing an existing deck, update the same plan path rather than creating a new disconnected plan. Keep the plan aligned with what has actually been created.
|
||||
|
||||
长任务推荐使用的可选字段:
|
||||
Recommended optional fields for long-running work:
|
||||
|
||||
- `deck_status`:当前页数、已知目标页数,以及最后验证的版本或时间戳。
|
||||
- `created_slides`:页码、已知页面 ID 和页面角色。
|
||||
- `assets_used`:来源、适用时的本地路径、已知上传令牌,以及使用它的页面。
|
||||
- `open_issues`:仍需修正的布局、文本适配、素材或一致性风险。
|
||||
- `deck_status`: current slide count, target slide count if known, and last verified revision or timestamp.
|
||||
- `created_slides`: page number, slide id when known, and the page role.
|
||||
- `assets_used`: source, local path when applicable, uploaded token when known, and which page uses it.
|
||||
- `open_issues`: known layout, text fit, asset, or consistency risks that still need correction.
|
||||
|
||||
不要因为之前的演示文稿用过某个模式就硬编码页码。应按页面角色和证据需求规划,例如“来源中有可读图时,方法概览页应使用图”,而不是把截图、图表或示意图绑定到固定页码。规划文件应描述决策规则,而不是僵硬模板序列。
|
||||
Do not hard-code a page number just because a previous deck used that pattern. Plan by page role and evidence need, such as "method overview pages should use a figure when the source has a readable figure" instead of binding screenshots, charts, or diagrams to a fixed page index. The plan should describe decision rules, not a rigid template sequence.
|
||||
|
||||
## 素材规划
|
||||
## Asset Planning
|
||||
|
||||
`material_inventory` 记录 XML 生成前的演示文稿级来源处理;`asset_need` 记录页面级视觉需求。两者都遵循 `asset-planning.md`。
|
||||
`asset_need` is metadata. It can describe a desired figure, diagram, chart, icon, logo, screenshot, or fallback shape-based visual, but it must not require web search, local download, or media upload.
|
||||
|
||||
本地素材优先于远程搜索。常见素材角色:
|
||||
Use an object for one planned asset, an array for multiple real needs, or `asset_type: "none"` when no asset is useful. Each planned asset must include:
|
||||
|
||||
- `background_reference`:用于理解主题、事实、受众或约束的非模板文档或链接。
|
||||
- `visual_asset`:可能出现在页面中的图片、截图、标志、图标、图表、示意图或论文图。
|
||||
- `copy_source`:作为内容输入的正文、提纲、笔记、PRD、报告或转写稿。
|
||||
- `data_source`:用于图表或表格的数据表、CSV/XLSX、指标或结构化数值。
|
||||
- `rewrite_source`:导入后承载二次创作的目标底稿,可提供背景、版式、图片资产和页面结构;即使其文案不可用,也不应只当作宽泛视觉线索。
|
||||
- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
|
||||
规划页面前,先解析所有附件路径,并写入 `material_inventory.inputs`。每个可用本地输入应包含:
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
- `source`:用户原始提供的路径或文件标签。
|
||||
- `resolved_path`:实际存在的本地路径;能被 `lark-cli` 使用时,优先记录相对当前工作目录的路径。
|
||||
- `kind`:一个或多个素材角色。
|
||||
- `usage`:它将如何影响叙事、视觉、数据或风格。
|
||||
- `status`:`available`、`imported`、`uploaded`、`read`、`skipped` 或 `missing`。
|
||||
- `notes`:可选映射细节,例如“用户提供的相对路径已按指定素材目录解析”。
|
||||
Good examples:
|
||||
|
||||
如果附件是可导入为 slides 的用户材料,并已导入为在线幻灯片,在已知时记录 `imported_xml_presentation_id` 或 `import_ticket`。按 `asset-planning.md` 判断角色;默认把可作为视觉底稿的导入 XML 作为 `rewrite_source` 使用,并记录 `target_xml_presentation_id`。
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
|
||||
对导入的用户材料,还要按 `asset-planning.md` 记录 `template_asset_strategy`:`preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed`。在同一个已导入演示文稿内创建或替换页面时,可以复用导入页的图片令牌;不要把 `<img src>` 令牌直接复制到另一个新演示文稿。如果导入的在线幻灯片文件会成为编辑后的最终交付物,记录 `target_title`,并在编辑完成后重命名在线文件,避免仍保留源材料或附件标题。异常新建只能由用户明确要求另建,或导入失败/回读失败触发,并必须记录原因和图片资产迁移方式。
|
||||
## XML Generation Contract
|
||||
|
||||
如果附件是 `rewrite_source`,每个受影响页面计划都应说明来源页或来源章节,以及预期操作:`preserve`、`condense`、`expand`、`reorder`、`restyle`、`replace_visual_only` 或 `delete`。对于“内容不用改”类请求,默认使用 `replace_visual_only` 或 `restyle`,并保持原始论断、数字、名称和页面意图不变。对于“材料页数多于目标页数”的任务,先规划要保留或改写的来源页,再删除无关页;不要因为需要压缩页数就另建脱离材料的新 deck。
|
||||
Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
|
||||
用户提供的 PDF/PPTX/slides 即使只参考风格,也不能脱离导入后的 presentation 新建;除非用户明确要求另建,或导入失败/回读失败。
|
||||
- `key_message` determines the headline, dominant claim, or main takeaway.
|
||||
- `layout_type` determines the coordinate structure and element types. Use `visual-planning.md` for concrete layout rules.
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
|
||||
单个计划素材使用对象,多个真实需求使用数组;没有有用素材时使用 `asset_type: "none"`。每个计划素材必须包含:
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
- `asset_type`:`paper_figure`、`architecture_diagram`、`icon`、`logo`、`chart`、`infographic`、`screenshot`、`flow_diagram` 或 `none`。
|
||||
- `purpose`:该素材为什么能帮助传达本页核心观点。
|
||||
- `suggested_query`:仅作为后续查找提示的短查询;除非另有要求,否则不要执行。
|
||||
- `fallback_if_missing`:使用形状、标签、表格、白板图或占位面板构成的具体 XML 原生兜底视觉方案。
|
||||
|
||||
详细规则和示例见 `asset-planning.md`。
|
||||
|
||||
合格示例:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"解释组件关系。","suggested_query":"服务架构图","fallback_if_missing":"用分组方框、连接箭头和短标签绘制组件图。"}`
|
||||
- `{"asset_type":"logo","purpose":"标识客户场景。","suggested_query":"客户标志","fallback_if_missing":"使用小徽标中的文字标签。"}`
|
||||
- `{"asset_type":"chart","purpose":"展示采用率趋势。","suggested_query":"月度采用率趋势图","fallback_if_missing":"绘制带轴标签和数据点的简单趋势折线图。"}`
|
||||
|
||||
## XML 生成约定
|
||||
|
||||
写每页页面 XML 前,把规划字段映射成具体决策:
|
||||
|
||||
- `key_message` 决定标题、主导论断或主要结论。
|
||||
- `material_inventory` 决定哪些本地素材、导入材料、文案来源、远程搜索结果或兜底方案允许影响页面。
|
||||
- `layout_type` 决定坐标结构和元素类型。具体布局规则见 `visual-planning.md`。
|
||||
- `visual_focus` 决定最大视觉区域或被强调对象。
|
||||
- `text_density` 限制可见文字量。
|
||||
- `asset_need` 只用于指导占位图、图标、图表、截图或基于形状的兜底视觉。缺失真实素材时必须使用 `fallback_if_missing`,不能留下空白区域。
|
||||
|
||||
创建或改写 PPT 后,以 `validation-checklist.md` 作为验证依据。验证记录还应说明规划专属映射:
|
||||
|
||||
- 页数与规划文件一致。
|
||||
- 计划中的 `key_message`、`layout_type`、`visual_focus`、`text_density` 和 `asset_need` 已体现在 XML 中。
|
||||
- `visual_system` 和 `typography_constraints` 明显影响了背景、结构、层级和文本位置。
|
||||
- 维护的 `deck_status`、`created_slides`、`assets_used` 或 `open_issues` 字段已更新为当前演示文稿状态。
|
||||
- Page count matches the plan.
|
||||
- Every page has the planned title and key message represented.
|
||||
- At least several pages have visibly different XML layout structures.
|
||||
- Planned `visual_focus` appears as a dominant visual region or object.
|
||||
- Asset planning is proportional to the deck topic and length: technical, research, product, and analytical decks should include meaningful planned visuals where they clarify the story, and each planned asset has a visible fallback if no real asset was used.
|
||||
- `text_density` is reflected in the amount of visible text.
|
||||
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
|
||||
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
|
||||
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
|
||||
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
<xs:simpleType name="FontFamilyType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
字体族名称, 支持下列字体。
|
||||
字体族名称, 支持任意字体。
|
||||
|
||||
常用中文字体:
|
||||
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、
|
||||
|
||||
@@ -23,11 +23,6 @@
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
## Imported Slides And Media Tokens
|
||||
|
||||
- 由 `drive +import --type slides` 导入生成的页面,可能可读、可截图,但不保证支持后续 `+replace-slide` / `xml_presentation.slide.replace` 的块级编辑。遇到导入页块级编辑失败时,先回读确认内容;需要重做结构时,优先新建 XML-native 页面或重建对应页,而不是反复尝试块级替换。
|
||||
- 一个演示文稿中的图片 `file_token` 不能直接复用于另一个新建演示文稿。跨文稿复用图片时必须在目标演示文稿重新上传,或在 `+create --slides` 中使用 `@./relative/path` 占位符让 CLI 为新文稿上传并替换 token。
|
||||
|
||||
## Symptom Fixes
|
||||
|
||||
| 看到的问题 | 处理方式 |
|
||||
@@ -40,9 +35,7 @@
|
||||
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1` / `dim2` 数据数量是否匹配 |
|
||||
| 图片被裁掉一部分 | `<img>` 的 `width` / `height` 是裁剪后尺寸;要整图显示就让 `width:height` 对齐原图比例 |
|
||||
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符只在 `+create --slides` 中替换;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` |
|
||||
| 新文稿里复用旧文稿的图片 `file_token` 后图片不显示 | `file_token` 不能跨演示文稿复用;在目标文稿重新 `+media-upload`,或新建时使用 `@./relative/path` 占位符 |
|
||||
| 新插入的 `<img>` 挡住原有元素 | `slide.get` 读原页,对照已有块坐标挑空白位置;空间不够就在同一批 `--parts` 里先移动/缩小现有块再插图 |
|
||||
| 导入的 PPTX/PDF 页面可读但 `+replace-slide` / `slide.replace` 失败 | 导入页不保证支持块级编辑;改为重建该页或新建 XML-native 页面,再删除/替换旧页 |
|
||||
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)` |
|
||||
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Validation Checklist
|
||||
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、异常换行、明显溢出、弱视觉层级和未验证输出。
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
|
||||
|
||||
小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML,确认目标元素已更新且未破坏周边结构。
|
||||
|
||||
@@ -9,15 +9,12 @@
|
||||
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。
|
||||
2. 用 `xml_presentations.get` 回读全文 XML。
|
||||
3. 检查实际页数是否符合计划或用户要求。
|
||||
4. 如果计划基于导入 PDF/PPTX/slides 材料二次创作,检查最终 `xml_presentation_id` 是否等于 `target_xml_presentation_id`;如果另建了 presentation,必须在验证记录中说明用户明确要求另建或导入/回读失败原因。
|
||||
5. 如果用户材料只用于模板风格或视觉线索,检查它是否仍作为 `rewrite_source` 导入/回读,并确认最终没有交付脱离该材料的新 deck。
|
||||
6. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
7. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
8. 检查页面不是全部退化为标题加 bullet list。
|
||||
9. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
10. 检查没有遗留模板占位文案、示例公司名、示例日期或与用户主题无关的源模板文字。
|
||||
11. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
12. 在最终回复中给出简短验证记录。
|
||||
4. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
6. 检查页面不是全部退化为标题加 bullet list。
|
||||
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
9. 在最终回复中给出简短验证记录。
|
||||
|
||||
回读命令:
|
||||
|
||||
@@ -37,8 +34,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
通过标准:
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 对异常换行、文本框高度不足等 wrap quality warning,默认也应修复后再交付;仅当它是普通正文的自然换行且用户明确允许时,才可在验证记录中说明豁免原因。
|
||||
- 当前工具检查 XML well-formed、文本元素之间的明显重叠,以及部分规则化异常换行;它不检查越界、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
|
||||
常见 code 的处理方向:
|
||||
@@ -47,15 +43,6 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|------|------|----------|
|
||||
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `text_word_split` / `text_phrase_split` | 中文词语或高频短语被异常拆行 | 增宽文本框、降低字号、改写短语或调整换行点,避免把词语/短语拆开 |
|
||||
| `text_orphan_line` | 最后一行只有极短中文尾巴 | 增宽文本框、缩小字号或重排文本,让尾行形成可读短句 |
|
||||
| `text_unnecessary_wrap` | 短标题或强调文本本应单行却换行 | 增宽文本框或缩小字号,优先保持单行 |
|
||||
| `text_center_wrapped` | 非封面/金句场景的多行文本居中 | 改为左对齐,或调整为真正的封面/金句元素 |
|
||||
| `text_box_too_short` | 文本框高度低于字号所需高度 | 增加文本框高度、降低字号或减少文本量 |
|
||||
|
||||
## Optional Screenshot Upgrade
|
||||
|
||||
如果截图或可视化预览能力可用,优先获取页面截图辅助判断最终效果;截图不能替代 XML 回读和页数核对。
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
@@ -74,8 +61,6 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- `visual_focus` 是页面中最醒目或最大的信息区域之一。
|
||||
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
|
||||
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
|
||||
- `template_asset_strategy` 为 `preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed` 时,不能交付一份脱离导入材料的新 deck。
|
||||
- 如果计划声明保留材料背景、装饰图、品牌图或复杂图片版式,回读 XML 中应仍存在对应的 `<img src>`、背景图片或等效重绘结构;如果未保留,验证记录必须说明原因。
|
||||
|
||||
如果用户指定了关键页,例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
|
||||
|
||||
@@ -104,7 +89,6 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
优先修复这些明显风险:
|
||||
|
||||
- 正文或标签框高度不足,文本很可能被截断。
|
||||
- 标题、标签、卡片标题或强调文本出现异常换行,例如拆词、拆短语、短尾行或本应单行却换行。
|
||||
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
|
||||
- 重要内容越过画布边界,或贴近底部超过 `y=500`。
|
||||
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
|
||||
@@ -118,14 +102,9 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
```text
|
||||
验证记录:
|
||||
- 回读:已执行 xml_presentations.get,实际页数 N / 预期 N。
|
||||
- 导入底稿:如使用导入材料二次创作,最终 presentation 与 target_xml_presentation_id 一致;如不一致,已说明用户明确要求另建或导入/回读失败原因。
|
||||
- 视觉底稿:如用户材料只提供模板风格或视觉线索,已确认它仍作为 rewrite_source 导入/回读并承载最终二创。
|
||||
- 内容来源:如模板材料内容不可用,已确认未复制其占位文案;正文来自计划中的 copy_source 或用户输入。
|
||||
- 截图:截图能力可用时,已用截图辅助判断最终效果。
|
||||
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
|
||||
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
|
||||
- 模板清理:未发现模板占位文案、示例公司名、示例日期或无关模板文字。
|
||||
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
```
|
||||
|
||||
不要声称完成了截图或人工视觉验收,除非确实打开、获取截图或拿到了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
|
||||
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
|
||||
|
||||
@@ -74,7 +74,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| `textAlign` | 文本对齐方式 |
|
||||
| `lineSpacing` | 行间距,schema 默认 `multiple:1.5` |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体,必须来自 `slides_xml_schema_definition.xml` 的 `FontFamilyType` 清单 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
|
||||
|
||||
|
||||
@@ -17,10 +17,6 @@ class XmlTextOverlapLintError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
TITLE_LIKE_TEXT_TYPES = {"title", "headline", "sub-headline", "card_title", "callout"}
|
||||
CENTER_ALLOWED_TEXT_TYPES = {"title", "quote", "hero"}
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise XmlTextOverlapLintError(message)
|
||||
|
||||
@@ -75,49 +71,10 @@ def strip_xml(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", stripped).strip()
|
||||
|
||||
|
||||
def collapse_space(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def chinese_char_count(value: str) -> int:
|
||||
return len(re.findall(r"[\u4e00-\u9fff]", value))
|
||||
|
||||
|
||||
def chinese_text(value: str) -> str:
|
||||
return "".join(re.findall(r"[\u4e00-\u9fff]", value))
|
||||
|
||||
|
||||
def xml_local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
|
||||
|
||||
|
||||
def extract_content_lines(content_xml: str) -> list[str]:
|
||||
try:
|
||||
root = ET.fromstring(f"<root>{content_xml}</root>")
|
||||
except ET.ParseError:
|
||||
text = strip_xml(content_xml)
|
||||
return [text] if text else []
|
||||
|
||||
lines: list[str] = []
|
||||
for content_node in root.iter():
|
||||
if xml_local_name(content_node.tag) != "content":
|
||||
continue
|
||||
paragraph_lines: list[str] = []
|
||||
for node in content_node.iter():
|
||||
if xml_local_name(node.tag) != "p":
|
||||
continue
|
||||
line = collapse_space("".join(node.itertext()))
|
||||
if line:
|
||||
paragraph_lines.append(line)
|
||||
if paragraph_lines:
|
||||
lines.extend(paragraph_lines)
|
||||
else:
|
||||
line = collapse_space("".join(content_node.itertext()))
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
|
||||
if line is None or column is None:
|
||||
return None
|
||||
@@ -182,23 +139,18 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
if all(value is not None for value in [x, y, width, height]):
|
||||
font_size = float(extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16)
|
||||
lines = extract_content_lines(content)
|
||||
raw_text = "\n".join(lines)
|
||||
elements.append(
|
||||
{
|
||||
"id": f"shape-{len(elements) + 1}",
|
||||
"kind": "shape",
|
||||
"type": extract_attribute(attrs, "type") or "shape",
|
||||
"textType": extract_attribute(content, "textType"),
|
||||
"textAlign": extract_attribute(content, "textAlign") or extract_attribute(attrs, "textAlign"),
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fontSize": font_size,
|
||||
"text": strip_xml(content),
|
||||
"rawText": raw_text,
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -342,223 +294,10 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def estimate_text_width(text: str, font_size: float) -> float:
|
||||
width = 0.0
|
||||
for char in text:
|
||||
if re.match(r"[\u4e00-\u9fff]", char):
|
||||
width += font_size
|
||||
elif char.isspace():
|
||||
width += font_size * 0.32
|
||||
else:
|
||||
width += font_size * 0.55
|
||||
return width
|
||||
|
||||
|
||||
def estimated_rendered_line_count(element: dict[str, Any]) -> int:
|
||||
return len(estimate_rendered_lines(element))
|
||||
|
||||
|
||||
def estimate_rendered_lines(element: dict[str, Any]) -> list[str]:
|
||||
lines = [line for line in element.get("lines", []) if line]
|
||||
if not lines:
|
||||
return []
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
usable_width = max(float(element["width"]) - 6, 1)
|
||||
rendered_lines: list[str] = []
|
||||
for line in lines:
|
||||
current = ""
|
||||
current_width = 0.0
|
||||
for char in line:
|
||||
char_width = estimate_text_width(char, font_size)
|
||||
if current and current_width + char_width > usable_width:
|
||||
rendered_lines.append(current)
|
||||
current = char
|
||||
current_width = char_width
|
||||
continue
|
||||
current += char
|
||||
current_width += char_width
|
||||
if current:
|
||||
rendered_lines.append(current)
|
||||
return rendered_lines
|
||||
|
||||
|
||||
def has_insufficient_height_for_estimated_wrap(element: dict[str, Any], estimated_line_count: int) -> bool:
|
||||
if estimated_line_count < 2:
|
||||
return False
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
required_height = estimated_line_count * font_size * 1.12
|
||||
return float(element["height"]) < required_height
|
||||
|
||||
|
||||
def has_too_short_text_box(element: dict[str, Any]) -> bool:
|
||||
text = element.get("text") or ""
|
||||
if chinese_char_count(text) < 6:
|
||||
return False
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
return float(element["height"]) < font_size * 0.95
|
||||
|
||||
|
||||
def is_slash_separated_short_label(text: str) -> bool:
|
||||
if "/" not in text:
|
||||
return False
|
||||
parts = [part.strip() for part in text.split("/") if part.strip()]
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
return chinese_char_count(text) <= 14 and all(chinese_char_count(part) <= 4 for part in parts)
|
||||
|
||||
|
||||
def is_short_display_text_auto_wrapped(element: dict[str, Any], rendered_lines: list[str]) -> bool:
|
||||
if len(element.get("lines", [])) != 1 or len(rendered_lines) != 2:
|
||||
return False
|
||||
if element.get("textType") in {"title", "caption"}:
|
||||
return False
|
||||
text = element.get("text") or ""
|
||||
chinese_count = chinese_char_count(text)
|
||||
if not (4 <= chinese_count <= 20):
|
||||
return False
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
if font_size < 20:
|
||||
return False
|
||||
if not has_insufficient_height_for_estimated_wrap(element, len(rendered_lines)):
|
||||
return False
|
||||
return chinese_count / max(len(text), 1) >= 0.6
|
||||
|
||||
|
||||
def build_wrap_issue(
|
||||
code: str,
|
||||
element: dict[str, Any],
|
||||
message: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"level": "warning",
|
||||
"code": code,
|
||||
"element": element["id"],
|
||||
"message": message,
|
||||
"reason": reason,
|
||||
"repair": {
|
||||
"prefer_single_line": True,
|
||||
"allow_font_shrink": True,
|
||||
"max_shrink_ratio": 0.9,
|
||||
"avoid_center_align": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_probable_cover_center_title(element: dict[str, Any]) -> bool:
|
||||
text_type = element.get("textType")
|
||||
if text_type == "quote":
|
||||
return True
|
||||
if text_type not in CENTER_ALLOWED_TEXT_TYPES:
|
||||
return False
|
||||
return element["x"] >= 120 and element["y"] >= 150 and element["width"] >= 300 and element["height"] >= 80
|
||||
|
||||
|
||||
def lint_wrap_quality(element: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not is_text_element(element) or not has_text_content(element):
|
||||
return []
|
||||
|
||||
lines = [line for line in element.get("lines", []) if line]
|
||||
rendered_lines = estimate_rendered_lines(element)
|
||||
estimated_line_count = len(rendered_lines)
|
||||
if len(lines) < 2 and estimated_line_count < 2 and not has_too_short_text_box(element):
|
||||
return []
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
raw_text = element.get("rawText") or "\n".join(lines)
|
||||
joined_chinese = chinese_text("".join(lines))
|
||||
joined_chinese_count = chinese_char_count(joined_chinese)
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
|
||||
last_line_chinese_count = chinese_char_count(lines[-1])
|
||||
previous_text_chinese_count = chinese_char_count("".join(lines[:-1]))
|
||||
if (
|
||||
len(lines) == 2
|
||||
and 1 <= last_line_chinese_count <= 3
|
||||
and previous_text_chinese_count >= 10
|
||||
):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_orphan_line",
|
||||
element,
|
||||
f"Last line is very short: {lines[-1]}",
|
||||
"最后一行是过短尾行",
|
||||
)
|
||||
)
|
||||
|
||||
if has_too_short_text_box(element):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_box_too_short",
|
||||
element,
|
||||
f"Text box height is too short for font size: height={element['height']}, fontSize={font_size:g}",
|
||||
"文本框高度低于字号所需高度,渲染后容易截断或压缩显示",
|
||||
)
|
||||
)
|
||||
|
||||
text_type = element.get("textType")
|
||||
estimated_single_line_width = joined_chinese_count * font_size * 0.62
|
||||
if (
|
||||
text_type in TITLE_LIKE_TEXT_TYPES
|
||||
and len(lines) >= 2
|
||||
and 1 <= joined_chinese_count <= 20
|
||||
and font_size >= 20
|
||||
and font_size < 40
|
||||
and chinese_char_count("".join(lines)) == len("".join(lines))
|
||||
and element["width"] >= estimated_single_line_width
|
||||
):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_unnecessary_wrap",
|
||||
element,
|
||||
f"Short title-like text wraps unnecessarily: {joined_chinese}",
|
||||
"短标题或强调文本不超过 20 个中文字符却出现换行",
|
||||
)
|
||||
)
|
||||
|
||||
if is_short_display_text_auto_wrapped(element, rendered_lines):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_unnecessary_wrap",
|
||||
element,
|
||||
f"Short display text is likely to wrap in a one-line box: {strip_xml(raw_text)}",
|
||||
"短展示文本被放入过窄且只够一行高度的文本框,渲染后容易异常换行",
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
(element.get("textAlign") or "").lower() == "center"
|
||||
and (
|
||||
(len(lines) >= 2 and font_size >= 22)
|
||||
or (
|
||||
len(lines) == 1
|
||||
and joined_chinese_count >= 8
|
||||
and has_insufficient_height_for_estimated_wrap(element, estimated_line_count)
|
||||
)
|
||||
)
|
||||
and text_type not in {"title", "sub-headline", "quote", "hero"}
|
||||
and not is_probable_cover_center_title(element)
|
||||
and not is_slash_separated_short_label(raw_text)
|
||||
):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_center_wrapped",
|
||||
element,
|
||||
f"Centered multi-line text is hard to scan: {strip_xml(raw_text)}",
|
||||
"非封面、非金句场景的多行文本使用居中对齐",
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
|
||||
elements = extract_elements(slide_xml)
|
||||
issues: list[dict[str, Any]] = []
|
||||
|
||||
for element in elements:
|
||||
issues.extend(lint_wrap_quality(element))
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
for right in elements[index + 1 :]:
|
||||
if not intersects(left, right) or not should_flag_overlap(left, right):
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import xml_text_overlap_lint
|
||||
|
||||
|
||||
def make_slide(shapes: str) -> str:
|
||||
return f"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>{shapes}</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
|
||||
|
||||
def text_shape(
|
||||
lines: list[str],
|
||||
*,
|
||||
text_type: str = "body",
|
||||
align: str = "left",
|
||||
x: int = 120,
|
||||
y: int = 120,
|
||||
width: int = 360,
|
||||
height: int = 120,
|
||||
font_size: int = 28,
|
||||
) -> str:
|
||||
paragraphs = "".join(f"<p>{line}</p>" for line in lines)
|
||||
return f"""
|
||||
<shape type="text" topLeftX="{x}" topLeftY="{y}" width="{width}" height="{height}">
|
||||
<content textType="{text_type}" textAlign="{align}" fontSize="{font_size}">
|
||||
{paragraphs}
|
||||
</content>
|
||||
</shape>
|
||||
"""
|
||||
|
||||
|
||||
class XmlTextOverlapWrapLintTest(unittest.TestCase):
|
||||
def lint_one(self, shape_xml: str) -> dict:
|
||||
result = xml_text_overlap_lint.lint_xml(make_slide(shape_xml))
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
return result
|
||||
|
||||
def issue_codes(self, result: dict) -> list[str]:
|
||||
return [
|
||||
issue["code"]
|
||||
for slide in result["slides"]
|
||||
for issue in slide["issues"]
|
||||
]
|
||||
|
||||
def assertWarnsCode(self, shape_xml: str, code: str) -> None:
|
||||
result = self.lint_one(shape_xml)
|
||||
self.assertIn(code, self.issue_codes(result))
|
||||
self.assertGreaterEqual(result["summary"]["warning_count"], 1)
|
||||
|
||||
def assertDoesNotWarnCode(self, shape_xml: str, code: str) -> None:
|
||||
result = self.lint_one(shape_xml)
|
||||
self.assertNotIn(code, self.issue_codes(result))
|
||||
|
||||
def test_wrap_lint_detects_orphan_line(self) -> None:
|
||||
cases = [
|
||||
["把排版看成一套可维护的规则", "系统"],
|
||||
["为什么大多数企业知识库最终都会", "失效"],
|
||||
["让内容生产流程持续保持稳定的", "质量"],
|
||||
["复杂协作权限需要清晰可读的继承", "边界"],
|
||||
["自动化检查应该优先发现低级排版", "问题"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertWarnsCode(text_shape(lines, width=520), "text_orphan_line")
|
||||
|
||||
def test_wrap_lint_allows_orphan_line_controls(self) -> None:
|
||||
cases = [
|
||||
["把排版看成", "一套可维护的规则系统"],
|
||||
["为什么大多数企业知识库", "最终都会失效"],
|
||||
["复杂协作权限需要", "清晰可读的继承边界"],
|
||||
["自动化检查应该", "优先发现低级排版问题"],
|
||||
["标题换行质量", "直接影响读者理解效率"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertDoesNotWarnCode(text_shape(lines, width=520), "text_orphan_line")
|
||||
|
||||
def test_wrap_lint_allows_multiline_body_with_short_final_line(self) -> None:
|
||||
shape_xml = text_shape(
|
||||
["按行业、阶段、投资年份分层;剔除信息不可得或标签不完整样本。"],
|
||||
align="left",
|
||||
width=146,
|
||||
height=42,
|
||||
font_size=10,
|
||||
)
|
||||
self.assertDoesNotWarnCode(shape_xml, "text_orphan_line")
|
||||
|
||||
def test_wrap_lint_detects_unnecessary_wrap_in_title_like_text(self) -> None:
|
||||
cases = [
|
||||
["减少手工", "格式"],
|
||||
["内容", "生产"],
|
||||
["智能", "生成"],
|
||||
["质量", "检查"],
|
||||
["边界", "规则"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertWarnsCode(text_shape(lines, text_type="headline", width=420), "text_unnecessary_wrap")
|
||||
|
||||
def test_wrap_lint_allows_unnecessary_wrap_controls(self) -> None:
|
||||
cases = [
|
||||
["减少手工格式"],
|
||||
["内容生产"],
|
||||
["智能生成"],
|
||||
["质量检查"],
|
||||
["边界规则"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertDoesNotWarnCode(text_shape(lines, text_type="headline", width=420), "text_unnecessary_wrap")
|
||||
|
||||
def test_wrap_lint_detects_short_display_text_that_will_auto_wrap(self) -> None:
|
||||
cases = [
|
||||
"模型、平台、数据、研究",
|
||||
"产业协同能力研究",
|
||||
"接口边界安全研究",
|
||||
"投后监测策略研究",
|
||||
"评分稳定性复盘研究",
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertWarnsCode(
|
||||
text_shape([text], width=190, height=26, font_size=26),
|
||||
"text_unnecessary_wrap",
|
||||
)
|
||||
|
||||
def test_wrap_lint_allows_body_text_that_will_auto_wrap(self) -> None:
|
||||
shape_xml = text_shape(
|
||||
["按行业、阶段、投资年份分层;剔除信息不可得或标签不完整样本。"],
|
||||
width=146,
|
||||
height=42,
|
||||
font_size=10,
|
||||
)
|
||||
self.assertDoesNotWarnCode(shape_xml, "text_unnecessary_wrap")
|
||||
|
||||
def test_wrap_lint_detects_center_wrapped_text(self) -> None:
|
||||
cases = [
|
||||
["下一代智能", "办公系统"],
|
||||
["企业知识库", "治理方案"],
|
||||
["自动化排版", "质量基线"],
|
||||
["协作权限", "继承模型"],
|
||||
["内容生产", "智能流程"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertWarnsCode(text_shape(lines, align="center", y=150), "text_center_wrapped")
|
||||
|
||||
def test_wrap_lint_detects_center_text_that_will_auto_wrap(self) -> None:
|
||||
shape_xml = text_shape(
|
||||
["平台价值:让数据、模型和流程在同一界面被调用、解释和追踪。"],
|
||||
align="center",
|
||||
width=248,
|
||||
height=12,
|
||||
font_size=10,
|
||||
)
|
||||
self.assertWarnsCode(shape_xml, "text_center_wrapped")
|
||||
|
||||
def test_wrap_lint_allows_center_wrapped_controls(self) -> None:
|
||||
cases = [
|
||||
text_shape(["下一代智能办公系统"], align="center"),
|
||||
text_shape(["企业知识库治理方案"], align="center"),
|
||||
text_shape(["自动化排版质量基线"], align="left"),
|
||||
text_shape(["封面主标题", "副标题"], text_type="title", align="center", y=210),
|
||||
text_shape(["金句内容", "保持居中"], text_type="quote", align="center"),
|
||||
text_shape(["企业筛选 / 排序 / 尽调建议"], align="center", width=132, height=20, font_size=10),
|
||||
text_shape(["经营异动 / 风险预警 / 里程碑"], align="center", width=136, height=12, font_size=10),
|
||||
text_shape(
|
||||
["建议采用 Top-N 命中率、风险预警召回率和评分稳定性三类指标,不只看单一准确率。"],
|
||||
align="left",
|
||||
width=146,
|
||||
height=42,
|
||||
font_size=10,
|
||||
),
|
||||
]
|
||||
for shape_xml in cases:
|
||||
with self.subTest(shape=shape_xml):
|
||||
self.assertDoesNotWarnCode(shape_xml, "text_center_wrapped")
|
||||
|
||||
def test_wrap_lint_detects_text_box_too_short(self) -> None:
|
||||
cases = [
|
||||
"REST API / 批量文件 / 定时同步",
|
||||
"鉴权、审计、脱敏与最小权限",
|
||||
"优先适配现有系统,减少重复建设",
|
||||
"服务化部署、权限隔离、日志留痕",
|
||||
"试运行三个月,终验后三年维保",
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertWarnsCode(
|
||||
text_shape([text], width=280, height=2, font_size=18),
|
||||
"text_box_too_short",
|
||||
)
|
||||
|
||||
def test_wrap_lint_allows_text_box_with_sufficient_height(self) -> None:
|
||||
cases = [
|
||||
"REST API / 批量文件 / 定时同步",
|
||||
"鉴权、审计、脱敏与最小权限",
|
||||
"优先适配现有系统,减少重复建设",
|
||||
"11",
|
||||
"KR1",
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertDoesNotWarnCode(
|
||||
text_shape([text], width=450, height=48, font_size=18),
|
||||
"text_box_too_short",
|
||||
)
|
||||
|
||||
def test_wrap_lint_keeps_bbox_overlap_detection(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
make_slide(
|
||||
text_shape(["Title"], text_type="title", x=80, y=80, width=300, height=60)
|
||||
+ text_shape(["Body"], text_type="body", x=80, y=80, width=300, height=80)
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertIn("bbox_overlap", self.issue_codes(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Metrics
|
||||
- Denominator: 31 leaf commands
|
||||
- Covered: 11
|
||||
- Coverage: 35.5%
|
||||
- Covered: 10
|
||||
- Coverage: 32.3%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
@@ -15,7 +15,6 @@
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveImportDryRun_PDFToSlides: dry-run coverage for `drive +import`; asserts PDF-to-slides request shape across media upload `extra` and import task body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -32,7 +31,7 @@
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✓ | drive +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRun_PDFToSlides | `.pdf` source with `--type slides`; media upload `extra.file_extension=pdf`; import task `file_extension=pdf`, `type=slides`, `file_name` | dry-run only; no live import workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
|
||||
@@ -143,14 +143,14 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
|
||||
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
|
||||
})
|
||||
|
||||
t.Run("export spreadsheet with +export as bot", func(t *testing.T) {
|
||||
t.Run("export spreadsheet with +workbook-export as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
outputDir := t.TempDir()
|
||||
outputPath := filepath.Join(outputDir, "export.xlsx")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+export",
|
||||
"sheets", "+workbook-export",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--file-extension", "xlsx",
|
||||
"--output-path", "./export.xlsx",
|
||||
|
||||
76
tests/cli_e2e/sheets/sheets_table_get_dryrun_test.go
Normal file
76
tests/cli_e2e/sheets/sheets_table_get_dryrun_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TableGetDefaultDryRun pins the request structure +table-get emits
|
||||
// when no --range is given: it must first read get_workbook_structure (to learn
|
||||
// each sheet's grid dimensions, which anchor the used-range probe over the full
|
||||
// grid) and then read cells via get_cell_ranges. This guards the pro016 / pro025
|
||||
// fix — the default read must span internal blank rows/columns, not stop at the
|
||||
// A1 current region.
|
||||
func TestSheets_TableGetDefaultDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// api.0 — the structure read that supplies the grid dimensions.
|
||||
require.Equal(t, "get_workbook_structure", gjson.Get(out, "api.0.body.tool_name").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/sheet_ai/v2/spreadsheets/shtDryRun/tools/invoke_read",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
|
||||
// api.1 — the cells read.
|
||||
require.Equal(t, "get_cell_ranges", gjson.Get(out, "api.1.body.tool_name").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSheets_TableGetSingleSheetDryRun confirms the single-sheet selector path
|
||||
// also reads get_workbook_structure now (previously it did not): the grid
|
||||
// dimensions are needed even when only one sheet is read, so the used-range
|
||||
// probe can anchor over the full grid.
|
||||
func TestSheets_TableGetSingleSheetDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheet-name", "Sheet1",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "get_workbook_structure", gjson.Get(out, "api.0.body.tool_name").String(),
|
||||
"single-sheet path must still read the structure for grid dimensions; stdout:\n%s", out)
|
||||
require.Equal(t, "get_cell_ranges", gjson.Get(out, "api.1.body.tool_name").String(), "stdout:\n%s", out)
|
||||
}
|
||||
118
tests/cli_e2e/sheets/sheets_table_get_workflow_test.go
Normal file
118
tests/cli_e2e/sheets/sheets_table_get_workflow_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TableGetUsedRangeWorkflow is the live regression for the pro016 /
|
||||
// pro025 incident: data with an internal blank row (and a blank separator
|
||||
// column) must read back in full when +table-get is run without --range. Before
|
||||
// the fix, the default read used the A1 current region, which stopped at the
|
||||
// first blank row/column and silently truncated everything past it.
|
||||
//
|
||||
// Layout written (header + 9 data rows, blank row at sheet row 6, blank column
|
||||
// D between the A:C block and the E:F block):
|
||||
//
|
||||
// row 1: name age city <blank> x1 x2
|
||||
// rows 2-5: data
|
||||
// row 6: <entirely blank>
|
||||
// rows 7-10: data
|
||||
//
|
||||
// The true used range is A1:F10. The default +table-get must return all 9 data
|
||||
// rows and 6 columns and report a range covering row 10 / column F.
|
||||
func TestSheets_TableGetUsedRangeWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := createSpreadsheet(t, parentT, ctx, "lark-cli-e2e-tableget-"+suffix, "bot")
|
||||
|
||||
infoRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
infoRes.AssertExitCode(t, 0)
|
||||
sheetID := gjson.Get(infoRes.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", infoRes.Stdout)
|
||||
|
||||
// Write the full A1:F10 block in one shot. Column D and row 6 are left blank
|
||||
// (empty strings) so the A1 current region would truncate at them.
|
||||
blank := ""
|
||||
values := [][]any{
|
||||
{"name", "age", "city", blank, "x1", "x2"},
|
||||
{"Alice", 30, "NY", blank, "p", "q"},
|
||||
{"Bob", 25, "LA", blank, "r", "s"},
|
||||
{"Carol", 40, "SF", blank, "t", "u"},
|
||||
{"Dave", 22, "TX", blank, "v", "w"},
|
||||
{blank, blank, blank, blank, blank, blank}, // row 6: entirely blank
|
||||
{"Eve", 33, "BOS", blank, "a", "b"},
|
||||
{"Frank", 28, "SEA", blank, "c", "d"},
|
||||
{"Grace", 45, "DEN", blank, "e", "f"},
|
||||
{"Hank", 50, "PHX", blank, "g", "h"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
writeRes, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:F10",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
writeRes.AssertExitCode(t, 0)
|
||||
writeRes.AssertStdoutStatus(t, true)
|
||||
|
||||
t.Run("default table-get spans the internal blank row/column", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-get",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
sheet := gjson.Get(result.Stdout, "data.sheets.0")
|
||||
rows := sheet.Get("data").Array()
|
||||
require.Equal(t, 9, len(rows),
|
||||
"default table-get must return all 9 data rows (not truncate at the blank row 6); stdout:\n%s", result.Stdout)
|
||||
|
||||
// The last data row must be present — the regression dropped everything
|
||||
// after the blank row.
|
||||
lastRow := rows[len(rows)-1].Array()
|
||||
assert.Equal(t, "Hank", lastRow[0].String(), "last data row should be Hank; stdout:\n%s", result.Stdout)
|
||||
|
||||
// Columns must span past the blank separator column D to reach x1 / x2.
|
||||
cols := sheet.Get("columns").Array()
|
||||
require.Equal(t, 6, len(cols), "must read all 6 columns across the blank column D; stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, "x1", cols[4].String())
|
||||
assert.Equal(t, "x2", cols[5].String())
|
||||
|
||||
// The reported range must cover the true used range (row 10, column F),
|
||||
// so a caller can detect truncation by inspecting it.
|
||||
rng := sheet.Get("range").String()
|
||||
require.NotEmpty(t, rng, "table-get output must report the range actually read; stdout:\n%s", result.Stdout)
|
||||
assert.Contains(t, rng, "10", "reported range should reach row 10; got %q", rng)
|
||||
assert.Contains(t, rng, "F", "reported range should reach column F; got %q", rng)
|
||||
})
|
||||
}
|
||||
85
tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go
Normal file
85
tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_TablePutStylesDryRun pins the request structure +table-put emits
|
||||
// when --styles is supplied: the set_cell_range write carries cell_styles merged
|
||||
// into the matrix, and the structural styles (merge / resize) render as their
|
||||
// own invoke_write tool calls afterward — the same shape +workbook-create uses.
|
||||
func TestSheets_TablePutStylesDryRun(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-put",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheets", `{"sheets":[{"name":"数据","columns":["a","b"],"data":[["x","y"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"数据","cell_styles":[{"range":"A1:B1","font_weight":"bold"}],"cell_merges":[{"range":"A1:B1"}],"col_sizes":[{"range":"A:A","type":"pixel","size":120}]}]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// api.0 — the typed write, with cell_styles merged into the cells matrix.
|
||||
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "/open-apis/sheet_ai/v2/spreadsheets/shtDryRun/tools/invoke_write",
|
||||
gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "set_cell_range", gjson.Get(out, "api.0.body.tool_name").String(), "stdout:\n%s", out)
|
||||
firstInput := gjson.Get(out, "api.0.body.input").String()
|
||||
require.Contains(t, firstInput, `"font_weight":"bold"`, "cell_styles should merge into the matrix; input:\n%s", firstInput)
|
||||
require.Contains(t, firstInput, `"range":"A1:B2"`, "write range should cover header + data; input:\n%s", firstInput)
|
||||
|
||||
// api.1 — the merge op.
|
||||
require.Equal(t, "merge_cells", gjson.Get(out, "api.1.body.tool_name").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.1.body.input").String(), `"range":"A1:B1"`, "stdout:\n%s", out)
|
||||
|
||||
// api.2 — the column resize.
|
||||
require.Equal(t, "resize_range", gjson.Get(out, "api.2.body.tool_name").String(), "stdout:\n%s", out)
|
||||
require.Contains(t, gjson.Get(out, "api.2.body.input").String(), `"type":"pixel"`, "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
// TestSheets_TablePutStylesNameMismatchRejected confirms a --styles item whose
|
||||
// name does not match the --sheets payload sheet is rejected up front (no write
|
||||
// lands), so a typo surfaces as a validation error rather than a silent skip.
|
||||
func TestSheets_TablePutStylesNameMismatchRejected(t *testing.T) {
|
||||
setSheetsDryRunEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+table-put",
|
||||
"--spreadsheet-token", "shtDryRun",
|
||||
"--sheets", `{"sheets":[{"name":"数据","columns":["a"],"data":[["x"]]}]}`,
|
||||
"--styles", `{"styles":[{"name":"其他","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "must match") {
|
||||
t.Fatalf("expected name-mismatch error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user