diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6df671..6d9494c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **Streaming-card mentions** — `chat +messages-send-card` now accepts + `--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention + targets to the initial card-creation request and prepending its returned + `atTag` to the automatic streaming update. + +## [1.0.58-beta.2] - 2026-08-10 + +### Added + +- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。 +- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。 +- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。 +- **`sheet replace --match-formula`** — 在公式文本中查找替换。 +- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。 +- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。 + +### Changed + +- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills + now document the `send` → `query-send-status` → `edit`/`recall` workflow, + so callers can reuse returned task, message, and conversation IDs instead + of searching message history by content. +- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet + reference with the progressive routing layout, aligns all 20 Sheet topic + references across the mono and multi bundles, and adds a content-policy guard + that prevents the paired topic trees from drifting again. +- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。 +- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。 +- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。 +- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。 +- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。 + ## [1.0.58-beta.1] - 2026-08-07 ### Added diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 0836f571..e3fef1f0 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.58-beta.1" + version "1.0.58-beta.2" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-arm64.tar.gz" - sha256 "34f7dabf1a97ed950c1cd331e5fb873a62c87632db249ca1b42bd2da925927f0" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz" + sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-darwin-amd64.tar.gz" - sha256 "c812ad070ac76f8ab5aefe2ae1b9cdf9217389b383296acb36070fc2f40b9890" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz" + sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-arm64.tar.gz" - sha256 "7dd5e38647c88e4b7936e0f41315cd612128faeb7960f50b28dbbd1fa9683d68" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz" + sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-linux-amd64.tar.gz" - sha256 "b384671440a62f1e5dca5dcdb49e51ef1aad816b601021ba363c8c69986ebd2f" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz" + sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.1/dws-skills.zip" - sha256 "2cf96aa2d35a01a4b92db2bd2d1760476d62d7528de5ec50a997c9a7713c3513" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip" + sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a" end def install diff --git a/docs/skill-content-framework.md b/docs/skill-content-framework.md index 1b179e8c..3730a8a3 100644 --- a/docs/skill-content-framework.md +++ b/docs/skill-content-framework.md @@ -101,5 +101,5 @@ skills/mono/ ## 7. 变更流程 1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit) -2. 跑 `make skill-mono-multi-content`(或 `make policy`) +2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中) 3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过 diff --git a/docs/skill-mono-multi-qa.md b/docs/skill-mono-multi-qa.md index aa2432a7..2c885474 100644 --- a/docs/skill-mono-multi-qa.md +++ b/docs/skill-mono-multi-qa.md @@ -2,7 +2,7 @@ > 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。 > 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。 -> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。 +> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。 ## 1. 质检矩阵 @@ -11,7 +11,8 @@ | **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` | | **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` | | **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 | -| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` | +| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` | +| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 | 已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。 @@ -37,6 +38,16 @@ orphan_scripts_allowlist: - path: dingtalk-misc/scripts/report_received_today.py disposition: defer reason: "pending report.md reference" + +paired_files: + - mono: references/products/sheet.md + multi: dingtalk-misc/references/sheet.md + mode: link-normalized + link_substitutions: + - mono: "../url-patterns.md" + multi: "../../dingtalk-shared/references/url-patterns.md" + - mono: "../intent-guide.md" + multi: "sheet-intent-guide.md" ``` **处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。 diff --git a/docs/skill-wukong-align-plan.md b/docs/skill-wukong-align-plan.md index de280a76..b2d64d0c 100644 --- a/docs/skill-wukong-align-plan.md +++ b/docs/skill-wukong-align-plan.md @@ -4,7 +4,7 @@ > 合同短文:[skill-content-framework.md](skill-content-framework.md) > 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md) > 机读合同:`skills/content-qa/mono-multi-coverage.yaml` -> 门禁:`make skill-mono-multi-content`(已入 `make policy`) +> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查) > > 撰写 / 收窄 / 质检增补 / 执行:2026-08-05 > 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align` diff --git a/internal/app/schema_interface_disposition_contract_test.go b/internal/app/schema_interface_disposition_contract_test.go index c92a8d18..cf5ffa90 100644 --- a/internal/app/schema_interface_disposition_contract_test.go +++ b/internal/app/schema_interface_disposition_contract_test.go @@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) { { canonical: "sheet.range_batch_set_style", mode: "composite", - reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.", + reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.", + }, + { + canonical: "sheet.create_with_data", + mode: "composite", + reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.", }, { canonical: "sheet.range_read", diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index 37471cc8..2595ee69 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -596,10 +596,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.", "sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.", - "sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.", - "sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.", + "sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.", + "sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.", "sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", + "sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.", + "sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.", "sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.", "sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.", "sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.", diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index ab82dd7e..89c66100 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2295,6 +2295,11 @@ func newChatCommand() *cobra.Command { 纯文本 / Markdown 消息(默认): 无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。 +返回值与后续操作: + 发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用 + dws chat message query-send-status --open-task-id + 获取 openMessageId 和 openConversationId,无需再按消息内容反查。 + 本地图片 / 文件消息: 统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送; .png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。 @@ -3381,7 +3386,10 @@ func newChatCommand() *cobra.Command { chatMessageQuerySendStatusCmd := &cobra.Command{ Use: "query-send-status", Short: "查询消息发送状态", - Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。`, + Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。 + +发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为 +chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。`, Example: ` dws chat message query-send-status --open-task-id # openTaskId 由 dws chat message send 返回`, RunE: func(cmd *cobra.Command, args []string) error { @@ -3428,8 +3436,11 @@ func newChatCommand() *cobra.Command { Short: "撤回用户发送的消息", Long: `撤回当前用户发送的消息。需要指定会话 ID 和消息 ID。`, Example: ` dws chat message recall --conversation-id --msg-id - # 查询会话 ID: dws chat search --query "群名" - # 消息 ID 可通过 dws chat message list 获取`, + + # 发送后撤回:send -> query-send-status -> recall + dws chat message send --group --text "待撤回的内容" + dws chat message query-send-status --open-task-id <上一步返回的openTaskId> + dws chat message recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err @@ -3486,12 +3497,16 @@ func newChatCommand() *cobra.Command { 推荐使用 --text 和可选 --title,CLI 会按 Markdown 消息规则生成 content:{"title":"标题","text":"正文"}。 也可以直接使用 --content 传入完整 Markdown content JSON。--text 和 --content 二选一。`, Example: ` dws chat message edit --conversation-id --msg-id --text "更新后的内容" + + # 发送后编辑:send -> query-send-status -> edit + dws chat message send --group --text "原始内容" + dws chat message query-send-status --open-task-id <上一步返回的openTaskId> + dws chat message edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId> --text "更新后的内容" + dws chat message edit --group --msg-id --title "标题" --text "更新后的内容" dws chat message edit --group --msg-id --text "<@all> 请查看" --at-all dws chat message edit --group --msg-id --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids - dws chat message edit --group --msg-id --content '{"title":"标题","text":"更新后的内容"}' - # 查询会话 ID: dws chat search --query "群名" - # 消息 ID 可通过 dws chat message list 获取`, + dws chat message edit --group --msg-id --content '{"title":"标题","text":"更新后的内容"}'`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil { return err diff --git a/internal/helpers/chat_message_help_test.go b/internal/helpers/chat_message_help_test.go new file mode 100644 index 00000000..fcf1548c --- /dev/null +++ b/internal/helpers/chat_message_help_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "strings" + "testing" +) + +func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) { + tests := []struct { + name string + command string + contains []string + notContain string + }{ + { + name: "send returns task ID", + command: "send", + contains: []string{ + "openTaskId", + "query-send-status --open-task-id ", + }, + }, + { + name: "query returns message and conversation IDs", + command: "query-send-status", + contains: []string{ + "openTaskId", + "openMessageId", + "openConversationId", + "chat message edit", + "chat message recall", + }, + }, + { + name: "edit includes post-send workflow", + command: "edit", + contains: []string{ + "send -> query-send-status -> edit", + "query-send-status --open-task-id <上一步返回的openTaskId>", + "edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>", + }, + notContain: "chat message list", + }, + { + name: "recall includes post-send workflow", + command: "recall", + contains: []string{ + "send -> query-send-status -> recall", + "query-send-status --open-task-id <上一步返回的openTaskId>", + "recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>", + }, + notContain: "chat message list", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := newChatCommand() + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs([]string{"message", test.command, "--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String()) + } + + help := output.String() + for _, want := range test.contains { + if !strings.Contains(help, want) { + t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help) + } + } + if test.notContain != "" && strings.Contains(help, test.notContain) { + t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help) + } + }) + } +} diff --git a/internal/helpers/coverage_pure_more_test.go b/internal/helpers/coverage_pure_more_test.go index c1fe9539..2c526eb6 100644 --- a/internal/helpers/coverage_pure_more_test.go +++ b/internal/helpers/coverage_pure_more_test.go @@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T) _ = isEmptyDataValidation(value) _ = isEmptyHyperlink(value) } - _ = fillIntMatrix(2, 3, 4) _ = maxColLenStr([][]string{{"a"}, {"b", "c"}}) _ = maxColLen2D([][]int{{1}, {2, 3}}) _ = checkMatrixShape(1, 2, 1, 2, "x") @@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T) {styleSpec{}, 1, 1}, } for _, style := range styles { - _ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{}) + _, _ = buildStyleCells(&style.spec, style.rows, style.cols) } for _, tc := range []struct { scalar, raw string @@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T) {"", `[[""]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum}, } { - _ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{}) + if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil { + _, _ = get(0, 0) + } } views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}} _, _ = findFilterViewByID(views, "one") diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index ec16acf6..01246531 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -44,6 +44,7 @@ func newSheetCommand() *cobra.Command { 命令结构: dws sheet create 创建钉钉表格文档 + dws sheet create-with-data 创建表格文档并写入初始数据(可选样式) dws sheet list 获取全部工作表列表 dws sheet info 获取指定工作表详情 dws sheet new 新建工作表 @@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command { dws sheet chart update 更新浮动图表 dws sheet chart delete 删除浮动图表 dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载) + dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘) dws sheet import 导入 xlsx/xls 为在线电子表格 dws sheet template list 获取表格模板列表 dws sheet template search 搜索表格模板 @@ -159,6 +161,10 @@ func newSheetCommand() *cobra.Command { }, }, }) + // 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite + // 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。 + createWithDataCmd := newSheetCreateWithDataCmd() + exportCsvCmd := newSheetExportCsvCmd() importCmd := newSheetImportCmd() templateCmd := newSheetTemplateCmd() tableCmds := newTableCmds() @@ -238,7 +244,7 @@ func newSheetCommand() *cobra.Command { standaloneCmds = append(standaloneCmds, mediaCmds...) standaloneCmds = append(standaloneCmds, floatImageCmds...) standaloneCmds = append(standaloneCmds, tableCmds...) - standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd) + standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd) // Register cross-product aliases for _, cmd := range standaloneCmds { diff --git a/internal/helpers/sheet_batch.go b/internal/helpers/sheet_batch.go index c2b48208..218a88fd 100644 --- a/internal/helpers/sheet_batch.go +++ b/internal/helpers/sheet_batch.go @@ -517,12 +517,12 @@ func newRangeBatchClearCmd() *cobra.Command { } operations := make([]any, 0, len(ranges)) for i, rng := range ranges { - idx := strings.Index(rng, "!") - if idx <= 0 || idx == len(rng)-1 { - return fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", i, rng) + // 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里 + // ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。 + sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i) + if err != nil { + return err } - sheetName := strings.TrimSpace(rng[:idx]) - rangeAddr := strings.TrimSpace(rng[idx+1:]) operations = append(operations, map[string]any{ "toolName": "clear_range", "input": map[string]any{ diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go new file mode 100644 index 00000000..4881df17 --- /dev/null +++ b/internal/helpers/sheet_create_with_data.go @@ -0,0 +1,1411 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/spf13/cobra" +) + +// ============================================================================ +// sheet create-with-data:创建表格并写入初始数据(可选样式)的编排 +// +// 这是多步流程,没有单一 MCP 工具承载: +// create_workspace_sheet → 探活 → 定位默认工作表 → 写数据 → 回读校验 → 应用样式 +// 所有结构/枚举校验都在发第一个请求之前完成,避免留下白建的空文档。 +// +// 之所以独立成一条命令而不是给 sheet create 加 flag:sheet create 的叶子契约是 +// interface_mode=mcp + interface_ref=create_workspace_sheet,即"一次直接 RPC"。 +// 把编排挂到那条叶子上会让 Schema 消费者以为 --values / --sheets / --styles 都是 +// create_workspace_sheet 的入参,接口审计、参数映射、后端能力判断全都拿到错误信息。 +// 本叶子如实声明 interface_mode=composite(composite 不得带 interface_ref,必须给 +// Reason),sheet create 则保持原样只做一次 RPC。 +// ============================================================================ + +func newSheetCreateWithDataCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create-with-data", + Short: "创建钉钉表格文档并写入初始数据(可选样式)", + Long: `创建一篇新的钉钉在线电子表格,并在创建后写入初始数据、可选地应用样式。 + +创建空表格请用 dws sheet create(单次调用);本命令是多步编排: + create_workspace_sheet → 等待文档就绪 → 定位默认工作表 → 写入数据 → 回读校验 → 应用样式 +中途失败会带上已创建的 nodeId 报错,便于续做或删除。 + +创建位置优先级: --folder > --workspace > 默认 (我的文档根目录) + +初始数据(--values 与 --sheets 二选一,必须给一个): + --values 二维 JSON 数组,裸值写入默认工作表 A1 起(单表快速建表,无表头/类型语义, + 复用 csv-put 通道,自动识别数字/布尔)。单元格只能是字符串/数字/布尔/null; + 上限 30000 单元格、编码为 CSV 后 2000000 字符 + --sheets typed table 数组,多工作表一次写入(复用 table-put 通道)。每个条目形如 + {"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]} + name、columns 必填;第一个条目写入默认工作表(自动重命名为其 name),其余按 name 自动新建。 + 字段名为 camelCase,只接受 name / columns / data / dtypes / formats / cellStyles / + startCell / mode / header / allowOverwrite(写错的键会被服务端静默丢弃,故一律拒绝; + 不接受 sheetId:文档尚未创建)。data 每行长度须等于 columns,单元格只能是 + 字符串/数字/布尔/null;dtypes、formats 的键须是 columns 里的列名(按 trim 后比较)。 + 单表写入上限 30000 单元格(含表头行)。 + +样式配置(--styles,可选;顶层键对齐飞书 snake_case,列表项内字段兼容 camelCase; +两级都拒绝未知键,避免写错的键被静默丢弃导致样式只应用一半): + {"styles":[{"name":"表名", + "cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC", + "font_family":"微软雅黑","number_format":"@", + "border_styles":{"bottom":{"style":"medium"}}}], + "row_sizes":[{"range":"1:1","type":"pixel","size":28}], + "col_sizes":[{"range":"A:D","type":"pixel","size":120}], + "cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]} + - 每项至少给 cell_styles / row_sizes / col_sizes / cell_merges 之一 + - 配 --sheets 时 styles 的项数/顺序/name 必须与 --sheets 子表一一对应;配 --values 时只给 1 项(name 忽略) + - 数据写入后按 cell_styles → row_sizes → col_sizes → cell_merges 顺序执行(非原子) + - row_sizes 的 type:pixel(需 size)/ standard(恢复默认行高)/ auto(按内容自适应) + - col_sizes 的 type:pixel(需 size)/ standard(恢复默认列宽)——与飞书一致,列宽不提供 auto + - merge_type 取 all/rows/columns`, + Example: ` # 创建并写入初始数据(默认工作表,裸二维值) + dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' + + # 创建多个带数据的工作表(typed table) + dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' + + # 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽、合并) + dws sheet create-with-data --name "带样式" --values '[["姓名","分数"],["张三","90"]]' \ + --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold","background_color":"#FFF2CC"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}],"col_sizes":[{"range":"A:B","type":"pixel","size":120}]}]}' + + # 指定创建位置 + dws sheet create-with-data --name "Q1 数据" --folder FOLDER_ID --values '[["月份","金额"],["1月",100]]'`, + RunE: func(cmd *cobra.Command, _ []string) error { + valuesStr, _ := cmd.Flags().GetString("values") + sheetsStr, _ := cmd.Flags().GetString("sheets") + stylesStr, _ := cmd.Flags().GetString("styles") + if valuesStr == "" && sheetsStr == "" { + return fmt.Errorf("--values 与 --sheets 必须给一个(只建空表格请用 dws sheet create)") + } + if valuesStr != "" && sheetsStr != "" { + return fmt.Errorf("--values 与 --sheets 二选一,不能同时指定") + } + createArgs := map[string]any{ + "name": mustGetFlag(cmd, "name"), + } + if v, _ := cmd.Flags().GetString("folder"); v != "" { + createArgs["folderId"] = v + } + if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { + createArgs["workspaceId"] = v + } + return runCreateSheetWithData(cmd, createArgs, valuesStr, sheetsStr, stylesStr) + }, + } + DeclareLeafMetadata(cmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "sheet", + Name: "create_with_data", + CanonicalPath: "sheet.create_with_data", + CLIPath: "sheet create-with-data", + PrimaryCLIPath: "sheet create-with-data", + }, + Description: "创建钉钉在线电子表格文档(axls)并写入初始数据,可选一并应用样式。", + DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建钉钉在线电子表格文档(axls)并写入初始数据,可选一并应用样式。", + UseWhen: []string{"需要新建一份钉钉在线电子表格并在建表时就带上初始数据(可含表头样式、行高列宽、合并)时"}, + AvoidWhen: []string{"只要空表格用 sheet create;往已有表格写数据用 sheet csv-put / table-put;只改样式用 sheet range set-style"}, + Examples: []string{ + "dws sheet create-with-data --name \"名单\" --values '[[\"姓名\",\"分数\"],[\"张三\",90]]'", + "dws sheet create-with-data --name \"报表\" --sheets '[{\"name\":\"一月\",\"columns\":[\"项目\",\"金额\"],\"data\":[[\"房租\",5000]]}]'", + }, + }, + // name 由 mustGetFlag 校验;这里发布 required 供 Schema 消费者识别 + Parameters: []contract.ParamDecl{ + {Name: "name", Required: boolPtr(true)}, + }, + }, + }) + cmd.Flags().String("name", "", "表格名称 (必填)") + cmd.Flags().String("folder", "", "目标文件夹 ID 或 URL") + cmd.Flags().String("workspace", "", "目标知识库 ID") + cmd.Flags().String("values", "", "初始数据,二维 JSON 数组,写入默认工作表 (与 --sheets 二选一)") + cmd.Flags().String("sheets", "", `多工作表 typed table JSON,如 '[{"name":"表名","columns":[...],"data":[[...]]}]' (与 --values 二选一)`) + cmd.Flags().String("styles", "", `写入数据后一并应用的视觉处理 JSON(对齐飞书):{"styles":[{"name":"表名","cell_styles":[{"range":"A1:D1","font_weight":"bold"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}],"col_sizes":[{"range":"A:D","type":"pixel","size":120}],"cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]}`) + return cmd +} + +func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, valuesStr, sheetsStr, stylesStr string) error { + // 先解析初始数据,避免创建了空文档后才发现 JSON 非法 + var values [][]any + var sheetSpecs []map[string]any + // 分流判据统一用 valuesStr(flag 是否给了),不能用 values != nil: + // `--values null` 是合法 JSON 且解析出 nil,两者不等价。 + useValues := valuesStr != "" + if useValues { + // UseNumber 保留数字字面量,不经过 float64:普通 Unmarshal 会把超过 2^53 + // 的整数舍入(雪花 ID 1234567890123456789 变成 ...768),而回读只校验 + // 单元格非空,这种篡改会被当成写入成功。订单号、雪花 ID 都是表格常见数据。 + dec := json.NewDecoder(strings.NewReader(valuesStr)) + dec.UseNumber() + if err := dec.Decode(&values); err != nil { + return fmt.Errorf("--values JSON 解析失败: %w", err) + } + if err := requireJSONEOF(dec, "--values"); err != nil { + return err + } + // null / [] 都写不出任何数据,必须在建文档之前拒掉, + // 否则会白建一个空文档再由回读校验报成"写入未生效"。 + if len(values) == 0 { + return fmt.Errorf("--values 不能为空(需要形如 '[[\"姓名\",\"分数\"],[\"张三\",90]]' 的二维数组)") + } + if err := validateValuesCells(values); err != nil { + return err + } + if !valuesHaveContent(values) { + return fmt.Errorf("--values 全部单元格为空,写不出任何数据(需要至少一个非空单元格)") + } + if err := validateValuesBudget(values); err != nil { + return err + } + } else { + specs, err := parseCreateSheetSpecs(sheetsStr) + if err != nil { + return err + } + sheetSpecs = specs + } + + // --styles 也先解析校验(含与 --sheets 的数量/顺序/name 一致性) + var styleOps []sheetStyleOps + if stylesStr != "" { + var expected []string + for _, s := range sheetSpecs { + name, _ := s["name"].(string) + expected = append(expected, name) + } + ops, err := parseCreateStyles(stylesStr, expected) + if err != nil { + return err + } + // 前置校验:在创建文档之前把每项的结构/枚举问题全部暴露(快速失败,避免白建空文档) + for i, o := range ops { + if _, err := planStyleOps("", "", o); err != nil { + return fmt.Errorf("--styles[%d]: %w", i, err) + } + } + styleOps = ops + } + + ctx := context.Background() + + if deps.Caller.DryRun() { + deps.Out.PrintKeyValue("操作", "创建表格并写入初始数据") + deps.Out.PrintKeyValue("名称", fmt.Sprintf("%v", createArgs["name"])) + return nil + } + + // json 模式下进度提示会污染 stdout,末尾统一输出创建结果 JSON。 + jsonMode := deps.Caller.Format() == "json" + progress := func(msg string) { + if !jsonMode { + deps.Out.PrintInfo(msg) + } + } + + progress("创建表格文档 ...") + createText, err := callMCPToolReturnText(ctx, "create_workspace_sheet", createArgs) + if err != nil { + return fmt.Errorf("创建表格失败: %w", err) + } + nodeID, err := parseCreatedNodeID(createText) + if err != nil { + return err + } + progress(fmt.Sprintf("表格已创建: nodeId=%s,等待文档就绪 ...", nodeID)) + + // 新建文档服务端仍在初始化,需先探活;否则写入可能返回成功但数据不落盘 + if err := waitSheetWritable(ctx, nodeID); err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但等待文档就绪失败: %w", nodeID, err) + } + + // 定位默认工作表 + defaultSheetID, err := resolveFirstSheetID(ctx, nodeID) + if err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但定位默认工作表失败: %w", nodeID, err) + } + progress("开始写入初始数据 ...") + + if useValues { + if err := writeValuesToSheet(ctx, nodeID, defaultSheetID, values); err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但写入数据失败: %w", nodeID, err) + } + // 回读校验:防"返回成功但未落盘"(新建文档初始化竞态)。 + // 必须校验输入里第一个**非空**的单元格,不能死盯 A1 —— + // [["","姓名"],[1,"张三"]] 这类首格为空的合法数据会被误报成写入失败。 + probeCell := firstNonEmptyValuesCell(values) + if err := verifyRangeNotEmpty(ctx, nodeID, defaultSheetID, probeCell); err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但初始数据写入未生效: %w;请对该文档重新执行 csv-put/range update 补写", nodeID, err) + } + } else { + // 复用默认工作表承载第一个 sheet:先把默认表重命名为 sheets[0].name, + // 之后 table_put 按 name 命中它并写入;其余 sheet 由 table_put 自动创建。 + // 避免残留一张空的默认工作表。 + firstName, _ := sheetSpecs[0]["name"].(string) + if err := callMCPToolSilent(ctx, "update_sheet", map[string]any{ + "nodeId": nodeID, "sheetId": defaultSheetID, "title": firstName, + }); err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但重命名默认工作表失败: %w", nodeID, err) + } + if err := callMCPToolSilent(ctx, "table_put", map[string]any{ + "nodeId": nodeID, + "sheets": sheetSpecs, + }); err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但写入初始数据失败: %w", nodeID, err) + } + // 逐表回读校验:table_put 可能返回成功但在新建文档初始化竞态下数据未落盘, + // 与 --values 分支同源的问题。table_put 会按 name 复用/新建工作表,因此 + // 重取一次 name→sheetId 映射,再按每个 spec 的首个预期非空单元格回读。 + sheetIDByName, err := resolveSheetIDsByName(ctx, nodeID) + if err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),数据已提交但无法回读校验(获取工作表列表失败): %w;请自行确认各工作表数据是否落盘,必要时用 sheet table-put 补写", nodeID, err) + } + for _, spec := range sheetSpecs { + name, _ := spec["name"].(string) + probeCell, hasContent := firstNonEmptySheetSpecCell(spec) + if !hasContent { + continue // 只有 name、无 columns/data 的工作表本就应为空,不回读 + } + sid, ok := sheetIDByName[name] + if !ok { + return fmt.Errorf("表格已创建 (nodeId=%s),但写入后未找到工作表 %q,其初始数据可能未落盘;请用 sheet table-put 对该文档补写", nodeID, name) + } + if err := verifyRangeNotEmpty(ctx, nodeID, sid, probeCell); err != nil { + return fmt.Errorf("表格已创建 (nodeId=%s),但工作表 %q 的初始数据写入未生效: %w;请用 sheet table-put 对该文档补写", nodeID, name, err) + } + } + } + + progress("初始数据写入完成。") + + // --styles:数据写入后按 cell_styles → row_sizes → col_sizes → cell_merges 顺序应用 + if len(styleOps) > 0 { + progress("应用样式配置 ...") + for i, ops := range styleOps { + // --values 模式只有一项,作用于默认表;--sheets 模式按顺序对应各子表(name 已校验一致) + targetSheet := defaultSheetID + if !useValues { + targetSheet = ops.Name + } + if err := applyStyleOps(ctx, nodeID, targetSheet, ops); err != nil { + return fmt.Errorf("表格已创建且数据已写入 (nodeId=%s),但 --styles[%d] 应用失败: %w", nodeID, i, err) + } + } + progress("样式应用完成。") + } + // 输出创建结果(含 nodeId / docUrl) + deps.Out.PrintRaw(createText) + return nil +} + +// parseCreateSheetSpecs 解析 --sheets 为 table_put 的 sheet spec 数组,并校验每个条目带 name。 +// 接受 JSON 数组、{"sheets":[...]} 或单个 spec 对象。 +func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { + var payload any + // 同 --values:UseNumber 保留数字字面量。specs 原样转发给 table_put, + // 若经过 float64 中转,records/data 里的大整数会在发出前就被改掉 + // (1234567890123456789 变成 1234567890123456800)。 + dec := json.NewDecoder(strings.NewReader(sheetsStr)) + dec.UseNumber() + if err := dec.Decode(&payload); err != nil { + return nil, fmt.Errorf("--sheets JSON 解析失败: %w", err) + } + if err := requireJSONEOF(dec, "--sheets"); err != nil { + return nil, err + } + var arr []any + switch v := payload.(type) { + case []any: + arr = v + case map[string]any: + // 只要出现 sheets 键就按包装对象处理:畸形包装({"sheets":"bad","name":"一月"}) + // 必须报错,不能退化成「把整个对象当单个 spec」—— 那样 sheets 字段会被当成 + // 未知字段,用户以为在传一组子表,实际只建了一张。 + if inner, wrapped := v["sheets"]; wrapped { + s, ok := inner.([]any) + if !ok { + return nil, fmt.Errorf("--sheets.sheets 必须是数组,实际是 %T", inner) + } + arr = s + } else { + arr = []any{v} // 单个 spec 对象 + } + default: + return nil, fmt.Errorf("--sheets 必须是 JSON 数组、{\"sheets\":[...]} 或单个 sheet spec 对象") + } + if len(arr) == 0 { + return nil, fmt.Errorf("--sheets 不能为空数组") + } + specs := make([]map[string]any, 0, len(arr)) + seen := make(map[string]int, len(arr)) + for i, item := range arr { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("--sheets[%d] 不是对象", i) + } + if v, present := m["name"]; present && v != nil { + if _, isStr := v.(string); !isStr { + return nil, fmt.Errorf("--sheets[%d].name 必须是字符串,实际是 %T", i, v) + } + } + name, _ := m["name"].(string) + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("--sheets[%d] 缺少必填的 name 字段(创建带数据时每个工作表必须命名)", i) + } + // 工作表名不能重复:样式接口按「ID 或名称」定位工作表,重名时命中哪一张 + // 由服务端决定,--styles 会作用到不确定的工作表上。而 --styles 又是建 + // 文档之后的非原子步骤,所以必须在建文档之前拒掉。 + if prev, dup := seen[name]; dup { + return nil, fmt.Errorf("--sheets[%d].name=%q 与 --sheets[%d] 重复;工作表名必须唯一,否则 --styles 按名称定位时会落到不确定的工作表上", i, name, prev) + } + seen[name] = i + // name 之外的字段同样要在建文档之前按 table_put 的输入契约验一遍 + if err := validateCreateSheetSpec(m); err != nil { + return nil, fmt.Errorf("--sheets[%d]: %w", i, err) + } + specs = append(specs, m) + } + return specs, nil +} + +// ── --sheets:table_put 输入契约的前置校验 ──────────────────────────────────── +// +// sheet spec 原样转发给 table_put,字段契约见 MCP 的 SheetTableSpec 与引擎侧 +// TablePutOptions: +// +// name / startCell / mode / header / allowOverwrite / +// columns(必填、非空、列名非空且不重复)/ data / dtypes / formats / cellStyles +// +// 只校验「对象类型 + name」是不够的: +// - 类型不对的字段(data 传成对象、columns 传成字符串)要等到 table_put 才被 +// 服务端拒绝,此时 create_workspace_sheet 与 update_sheet 已经执行,留下一份 +// 用户并没有请求到的空/半成品文档,且无法回滚。 +// - 拼错的字段更糟:MCP 侧反序列化到 SheetTableSpec 会静默丢掉不认识的键, +// `datas` 会变成「只写了表头、数据全丢」,而回读探针刚好落在表头上, +// 整条命令报成功。 +// +// 因此把契约里的每个已提供字段都在发第一个请求之前验完。 + +// requireJSONEOF 确认解码器在读出第一个 JSON 值之后就到了输入末尾。 +// +// json.Decoder.Decode 只读一个值就返回,`[[1]] trailing` 会被当成合法矩阵: +// 粘贴多了一段、shell 拼接残留、把两个 JSON 首尾相连,都会静默丢掉后半截并照常 +// 建文档。创建不可原子回滚,所以这类输入必须在发第一个请求之前拒掉,而不是建完 +// 文档只写进去半份数据。(--styles 走 json.Unmarshal,它本身就拒绝多余内容。) +func requireJSONEOF(dec *json.Decoder, flag string) error { + if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) { + return fmt.Errorf("%s 只接受单个 JSON 值,末尾有多余内容", flag) + } + return nil +} + +const maxTableWriteCells = 30000 + +// tablePutSpecFields 是 table_put 单个 sheet spec 的合法字段(契约字段名为 camelCase)。 +// sheetId 虽在契约内但不在此列:见 validateCreateSheetSpec。 +var tablePutSpecFields = []string{ + "name", "startCell", "mode", "header", "allowOverwrite", + "columns", "data", "dtypes", "formats", "cellStyles", +} + +// looseFieldKey 归一化字段名:去掉下划线/连字符并转小写。 +func looseFieldKey(s string) string { + return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s)) +} + +// rejectUnknownFields 校验对象只含契约里的字段名。allowed 列出所有被接受的拼写 +// (同一字段的多种别名都要列出,首个视为规范拼写);归一化后能对上的键判为拼写 +// 偏差并直接指向规范拼写,其余判为未知字段。 +// +// 之所以要拒而不是忽略:这些 JSON 最终交给「按固定字段名取值」的下游(服务端 +// DTO 或 pickStr),不认识的键会被静默丢掉——写错一个键的后果是数据/样式部分丢失 +// 而命令报成功,比直接报错难发现得多。 +func rejectUnknownFields(item map[string]any, what string, allowed []string) error { + exact := make(map[string]bool, len(allowed)) + canonicalByLoose := make(map[string]string, len(allowed)) + for _, f := range allowed { + exact[f] = true + if _, dup := canonicalByLoose[looseFieldKey(f)]; !dup { + canonicalByLoose[looseFieldKey(f)] = f + } + } + canonical := make([]string, 0, len(canonicalByLoose)) + for _, f := range allowed { + if canonicalByLoose[looseFieldKey(f)] == f { + canonical = append(canonical, f) + } + } + for _, key := range sortedMapKeys(item) { + if exact[key] { + continue + } + if want, ok := canonicalByLoose[looseFieldKey(key)]; ok { + return fmt.Errorf("字段 %q 拼写不符合契约,应为 %q", key, want) + } + return fmt.Errorf("未知字段 %q(%s只接受 %s)", key, what, strings.Join(canonical, " / ")) + } + return nil +} + +// normalizeSpecStartCell 把 startCell 归一化成 parseA1Cell 认的形态:服务端解析 +// 前会 toUpperCase,且允许 $ 绝对引用($C$3 等价于 C3)。本地校验与回读探针必须与 +// 服务端同一套解释——否则要么误拒服务端接受的写法,要么探到错位的单元格。 +func normalizeSpecStartCell(s string) string { + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(s), "$", "")) +} + +// sortedMapKeys 返回排序后的键,保证校验顺序与错误信息稳定(map 遍历是随机的)。 +func sortedMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// validateCreateSheetSpec 按 table_put 的输入契约校验单个 sheet spec 的每个已提供 +// 字段。纯函数、不发请求:调用方在创建文档之前调用,非法输入不会产生任何 MCP 调用。 +func validateCreateSheetSpec(spec map[string]any) error { + // sheetId 在 table_put 契约里合法(且优先级高于 name),但对 create-with-data + // 没有意义:文档此刻还不存在,任何 sheetId 都不可能是它的工作表。传了只会 + // 让写入落到别处或直接失败,而重命名默认表、回读校验都是按 name 定位的。 + for _, key := range sortedMapKeys(spec) { + if looseFieldKey(key) == "sheetid" { + return fmt.Errorf("不支持 sheetId:文档尚未创建,工作表 ID 无从得知;请只用 name 指定工作表") + } + } + if err := rejectUnknownFields(spec, "sheet spec ", tablePutSpecFields); err != nil { + return err + } + + columns, err := validateSpecColumns(spec) + if err != nil { + return err + } + dataRows, err := validateSpecData(spec, columns) + if err != nil { + return err + } + for _, field := range []string{"dtypes", "formats"} { + if err := validateSpecColumnMap(spec, field, columns); err != nil { + return err + } + } + if v, ok := spec["cellStyles"]; ok && v != nil { + switch v.(type) { + case map[string]any, []any: + default: + return fmt.Errorf("cellStyles 必须是对象或二维数组,实际是 %T", v) + } + } + // mode / startCell 留空等价于不传(服务端按 blank 处理),非空才校验取值。 + if v, ok := spec["mode"]; ok && v != nil { + s, isStr := v.(string) + if !isStr { + return fmt.Errorf("mode 必须是字符串,实际是 %T", v) + } + if strings.TrimSpace(s) != "" && s != "overwrite" && s != "append" { + return fmt.Errorf("mode=%q 非法(合法值: overwrite / append)", s) + } + } + for _, field := range []string{"header", "allowOverwrite"} { + if v, ok := spec[field]; ok && v != nil { + if _, isBool := v.(bool); !isBool { + return fmt.Errorf("%s 必须是布尔值,实际是 %T", field, v) + } + } + } + if v, ok := spec["startCell"]; ok && v != nil { + s, isStr := v.(string) + if !isStr { + return fmt.Errorf("startCell 必须是字符串,实际是 %T", v) + } + if normalized := normalizeSpecStartCell(s); normalized != "" { + if _, _, err := parseA1Cell(normalized); err != nil { + return fmt.Errorf("startCell=%q 不是合法的单元格地址(应形如 A1,单个单元格)", s) + } + } + } + // 写入规模上限是服务端硬限,超限同样只会在 table_put 阶段报错。 + rows := dataRows + if specWritesHeader(spec) { + rows++ + } + if total := rows * len(columns); total > maxTableWriteCells { + return fmt.Errorf("单个工作表写入单元格总数上限为 %d(当前 %d 行 × %d 列 = %d)", + maxTableWriteCells, rows, len(columns), total) + } + return nil +} + +// validateSpecColumns 校验必填的 columns 并返回服务端实际使用的列名(已 trim)。 +func validateSpecColumns(spec map[string]any) ([]string, error) { + raw, ok := spec["columns"] + if !ok || raw == nil { + return nil, fmt.Errorf("缺少必填的 columns(table_put 要求每个工作表给出非空的列名数组)") + } + list, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("columns 必须是字符串数组,实际是 %T", raw) + } + if len(list) == 0 { + return nil, fmt.Errorf("columns 不能为空数组") + } + columns := make([]string, 0, len(list)) + seen := make(map[string]int, len(list)) + for i, item := range list { + s, isStr := item.(string) + if !isStr { + return nil, fmt.Errorf("columns[%d] 必须是字符串,实际是 %T", i, item) + } + // 服务端按 trim 后的列名判重、写表头、并以其作为 dtypes/formats 的查表键, + // 本地校验必须用同一套归一化,否则 ["量","量 "] 会通过本地检查再被服务端拒绝。 + name := strings.TrimSpace(s) + if name == "" { + return nil, fmt.Errorf("columns[%d] 不能为空字符串(列名不可为空)", i) + } + if prev, dup := seen[name]; dup { + return nil, fmt.Errorf("columns[%d]=%q 与 columns[%d] 重复(列名不可重复)", i, name, prev) + } + seen[name] = i + columns = append(columns, name) + } + return columns, nil +} + +// validateSpecData 校验可选的 data 形状(二维、行宽等于 columns、单元格为标量), +// 返回数据行数供写入规模校验使用。data 缺省或为 null 等价于空数据。 +func validateSpecData(spec map[string]any, columns []string) (int, error) { + raw, ok := spec["data"] + if !ok || raw == nil { + return 0, nil + } + rows, ok := raw.([]any) + if !ok { + return 0, fmt.Errorf("data 必须是二维数组,实际是 %T", raw) + } + for i, r := range rows { + row, isRow := r.([]any) + if !isRow { + return 0, fmt.Errorf("data[%d] 必须是数组,实际是 %T", i, r) + } + if len(row) != len(columns) { + return 0, fmt.Errorf("data[%d] 有 %d 列,与 columns 的 %d 列不一致(每行长度必须与 columns 一致)", + i, len(row), len(columns)) + } + for j, cell := range row { + if !isTableScalar(cell) { + return 0, fmt.Errorf("data[%d][%d] 必须是字符串/数字/布尔/null,实际是 %T(不支持嵌套数组或对象)", i, j, cell) + } + } + } + return len(rows), nil +} + +// validateSpecColumnMap 校验 dtypes / formats:必须是「列名 → 字符串」的对象,且键 +// 必须是 columns 里的列名 —— 服务端按列名查表,键写错既不报错也不生效,静默失效 +// 比报错更难发现。 +func validateSpecColumnMap(spec map[string]any, field string, columns []string) error { + raw, ok := spec[field] + if !ok || raw == nil { + return nil + } + m, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("%s 必须是对象(列名 → 字符串),实际是 %T", field, raw) + } + known := make(map[string]bool, len(columns)) + for _, c := range columns { + known[c] = true + } + for _, key := range sortedMapKeys(m) { + if _, isStr := m[key].(string); !isStr { + return fmt.Errorf("%s[%q] 必须是字符串,实际是 %T", field, key, m[key]) + } + if !known[key] { + return fmt.Errorf("%s 的键 %q 不是 columns 中的列名(服务端按列名查表,会静默忽略)", field, key) + } + } + return nil +} + +// isTableScalar 判断单元格取值是否落在契约允许的标量范围(string / number / +// boolean / null)。嵌套数组或对象会被服务端拒绝,或(--values 通道)被 fmt 写成 +// "map[a:1]" 这类无意义文本。UseNumber 解码出的数字是 json.Number。 +func isTableScalar(v any) bool { + switch v.(type) { + case nil, string, bool, json.Number, float64, int: + return true + } + return false +} + +// specWritesHeader 判断该 spec 是否会写入表头行:显式 header 优先;缺省时写表头 +// (overwrite 默认写;append 落在空表上也写,而本命令的工作表都是新建空表)。 +func specWritesHeader(spec map[string]any) bool { + if v, ok := spec["header"].(bool); ok { + return v + } + return true +} + +// validateValuesCells 校验 --values 的单元格取值同样是标量。嵌套对象/数组会被 +// cellToString 写成 "map[a:1]",回读校验只看非空,于是垃圾数据被报成写入成功。 +func validateValuesCells(values [][]any) error { + for i, row := range values { + for j, cell := range row { + if !isTableScalar(cell) { + return fmt.Errorf("--values[%d][%d] 必须是字符串/数字/布尔/null,实际是 %T"+ + "(不支持嵌套数组或对象;富格式单元格请用 sheet range update)", i, j, cell) + } + } + } + return nil +} + +// validateValuesBudget 校验 --values 不超过 set_range_from_csv 的服务端硬限 +// (行 × 列 ≤ 30000,CSV ≤ 2M 字符)。超限只会在写入阶段失败,而那时文档已经建好。 +func validateValuesBudget(values [][]any) error { + cols := 0 + for _, row := range values { + if len(row) > cols { + cols = len(row) + } + } + if total := len(values) * cols; total > maxCSVWriteCells { + return fmt.Errorf("--values 单元格总数上限为 %d(当前 %d 行 × %d 列 = %d)", + maxCSVWriteCells, len(values), cols, total) + } + if n := utf8.RuneCountInString(valuesToCSV(values)); n > maxCSVWriteChars { + return fmt.Errorf("--values 编码为 CSV 后长度为 %d 字符,超过上限 %d", n, maxCSVWriteChars) + } + return nil +} + +// set_range_from_csv 的服务端硬限:解析后行 × 列 ≤ 30000,csv 文本 ≤ 2M 字符。 +const ( + maxCSVWriteCells = 30000 + maxCSVWriteChars = 2 * 1000 * 1000 +) + +// ── create-with-data --styles:对齐飞书 +workbook-create --styles 协议 ──────── +// +// {"styles":[{"name":"子表名", +// +// "cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC", +// "border_styles":{"bottom":{"style":"medium"}}}], +// "row_sizes":[{"range":"1:1","type":"pixel","size":28}], +// "col_sizes":[{"range":"A:D","type":"pixel","size":120}], +// "cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]} +// +// 顶层四个列表键与飞书一致(snake_case);列表项内部的字段名同时兼容 DWS 的 +// camelCase 写法。两处都拒绝未知键:这些 JSON 落到固定字段名的结构体/pickStr 上, +// 写错的键会被静默丢弃,导致样式只应用了一部分而命令报成功。 +// +// styleItemFields / cellStyleItemFields / sizeItemFields / mergeItemFields 必须与 +// sheetStyleOps 的 json tag、cellStyleItemToSpec 与 planStyleOps 里的取值键保持一致。 +var ( + styleItemFields = []string{"name", "cell_styles", "row_sizes", "col_sizes", "cell_merges"} + cellStyleItemFields = []string{ + "range", + "background_color", "backgroundColor", "bgColor", + "font_color", "fontColor", + "font_family", "fontFamily", + "font_style", "fontStyle", + "font_weight", "fontWeight", + "font_line", "fontLine", + "font_size", "fontSize", + "horizontal_alignment", "horizontalAlignment", "hAlign", + "vertical_alignment", "verticalAlignment", "vAlign", + "word_wrap", "wordWrap", + "number_format", "numberFormat", + "border_styles", "borderStyles", + } + sizeItemFields = []string{"range", "type", "size"} + mergeItemFields = []string{"range", "merge_type", "mergeType"} +) + +// sheetStyleOps 是 --styles 数组的单项:对应一张子表的视觉处理操作。 +type sheetStyleOps struct { + Name string `json:"name"` + CellStyles []map[string]any `json:"cell_styles"` + RowSizes []map[string]any `json:"row_sizes"` + ColSizes []map[string]any `json:"col_sizes"` + CellMerges []map[string]any `json:"cell_merges"` +} + +func (o sheetStyleOps) isEmpty() bool { + return len(o.CellStyles) == 0 && len(o.RowSizes) == 0 && len(o.ColSizes) == 0 && len(o.CellMerges) == 0 +} + +// pickStr 按多个候选键取字符串(snake_case 优先,兼容 camelCase)。 +func pickStr(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + if s, ok := v.(string); ok && s != "" { + return s + } + } + } + return "" +} + +// pickNum 按多个候选键取整数值(JSON 数字为 float64)。 +// +// JSON 解码后所有数字都是 float64,直接 int(n) 会把 12.9 悄悄变成 12、把 1e20 +// 截成 math.MaxInt64。文档和错误信息都写明这些字段必须是正整数,而 --styles +// 是不可原子回滚的复合流程,静默取整会留下与配置不符的表格。因此非整数、 +// 非有限值、超出 int 范围一律报错,交由调用方在建文档之前拒绝。 +func pickNum(m map[string]any, keys ...string) (int, bool, error) { + for _, k := range keys { + v, ok := m[k] + if !ok || v == nil { + continue + } + switch n := v.(type) { + case float64: + if math.IsNaN(n) || math.IsInf(n, 0) { + return 0, true, fmt.Errorf("%s=%v 不是有限数值", k, n) + } + if n != math.Trunc(n) { + return 0, true, fmt.Errorf("%s=%v 必须是整数,不接受小数(静默取整会得到与配置不符的结果)", k, n) + } + if n > math.MaxInt32 || n < math.MinInt32 { + return 0, true, fmt.Errorf("%s=%v 超出取值范围", k, n) + } + return int(n), true, nil + case int: + return n, true, nil + default: + return 0, true, fmt.Errorf("%s 必须是数字,实际是 %T", k, v) + } + } + return 0, false, nil +} + +// feishuWordWrap 把飞书 word_wrap 枚举映射为引擎枚举(同时接受引擎原生值)。 +func feishuWordWrap(v string) string { + switch v { + case "auto-wrap": + return "autoWrap" + case "word-clip": + return "clip" + default: + return v // overflow / autoWrap / clip 原样 + } +} + +// mergeCellsTypeEnum 是 merge_cells 接受的原生 mergeType 取值。 +var mergeCellsTypeEnum = map[string]bool{ + "mergeAll": true, "mergeRows": true, "mergeColumns": true, +} + +// feishuMergeType 把飞书 merge_type 映射为 MCP mergeType(同时接受原生值)。 +// +// 未知值必须报错而不能原样透传:planStyleOps 是创建文档**之前**的最后一道枚举 +// 校验,放过去的后果是「文档已建 → 数据已写 → cell_styles/row_sizes/col_sizes +// 已刷 → 最后 merge_cells 被服务端拒绝」,留下一个部分修改过的新文档,正是这段 +// 编排声称要避免的非原子副作用。 +func feishuMergeType(v string) (string, error) { + switch v { + case "", "all": + return "mergeAll", nil + case "rows": + return "mergeRows", nil + case "columns": + return "mergeColumns", nil + } + if mergeCellsTypeEnum[v] { + return v, nil + } + return "", fmt.Errorf("merge_type 非法: %q(合法值: all / rows / columns,或原生 mergeAll / mergeRows / mergeColumns)", v) +} + +// cellStyleItemToSpec 把飞书 cell_styles 单项转为 styleSpec + range,复用 buildStyleCells 的校验。 +func cellStyleItemToSpec(item map[string]any) (*styleSpec, string, error) { + if err := rejectUnknownFields(item, "cell_styles 项", cellStyleItemFields); err != nil { + return nil, "", err + } + rangeAddr := pickStr(item, "range") + if rangeAddr == "" { + return nil, "", fmt.Errorf("cell_styles 项缺少必填的 range") + } + spec := &styleSpec{ + BgColor: pickStr(item, "background_color", "backgroundColor", "bgColor"), + FontColor: pickStr(item, "font_color", "fontColor"), + FontFamily: pickStr(item, "font_family", "fontFamily"), + FontStyle: pickStr(item, "font_style", "fontStyle"), + FontWeight: pickStr(item, "font_weight", "fontWeight"), + FontLine: pickStr(item, "font_line", "fontLine"), + HAlign: pickStr(item, "horizontal_alignment", "horizontalAlignment", "hAlign"), + VAlign: pickStr(item, "vertical_alignment", "verticalAlignment", "vAlign"), + WordWrap: feishuWordWrap(pickStr(item, "word_wrap", "wordWrap")), + NumberFormat: pickStr(item, "number_format", "numberFormat"), + } + if n, ok, err := pickNum(item, "font_size", "fontSize"); err != nil { + return nil, "", fmt.Errorf("cell_styles.%w", err) + } else if ok { + spec.FontSize = n + } + // border_styles 是对象,转回 JSON 字符串交给已有的 parseBorderStyles 校验。 + // 入参来自 json.Unmarshal,不含 channel/func/NaN,所以 Marshal 不会失败。 + for _, k := range []string{"border_styles", "borderStyles"} { + if bs, ok := item[k]; ok && bs != nil { + raw, _ := json.Marshal(bs) + spec.BorderStylesJSON = string(raw) + break + } + } + return spec, rangeAddr, nil +} + +// isAllLetters 判断字符串是否非空且只含英文字母(大小写皆可),用于列范围 +// 预检:拒绝 "A5" / "Ax" 这类带数字或尾随字符、会被 parseA1Cell 误判的列名。 +func isAllLetters(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') { + return false + } + } + return true +} + +// parseRowColRange 解析行/列范围:"1:3"→(start "1", len 3);"A:C"→(start "A", len 3);"1"/"A"→len 1。 +func parseRowColRange(addr string, isRow bool) (start string, length int, err error) { + addr = strings.TrimSpace(addr) + if i := strings.Index(addr, "!"); i >= 0 { + addr = addr[i+1:] + } + if addr == "" { + return "", 0, fmt.Errorf("range 不能为空") + } + parts := strings.SplitN(addr, ":", 2) + a := strings.TrimSpace(parts[0]) + b := a + if len(parts) == 2 { + b = strings.TrimSpace(parts[1]) + } + if isRow { + // strconv.Atoi 要求整个字符串都是合法整数,拒绝 "1x" / "2foo" 这类带尾随 + // 字符的输入。fmt.Sscanf("%d") 只消费前缀数字,会把 "1x" 静默当成第 1 行 —— + // 这是建文档前的校验关口,放过后 update_dimension 会改到错误的行,且此时 + // 文档与数据已创建、无法原子回滚。 + r1, e1 := strconv.Atoi(a) + r2, e2 := strconv.Atoi(b) + if e1 != nil || e2 != nil || r1 < 1 || r2 < 1 { + return "", 0, fmt.Errorf("行范围非法: %s(应形如 \"1:3\")", addr) + } + if r2 < r1 { + r1, r2 = r2, r1 + } + return fmt.Sprintf("%d", r1), r2 - r1 + 1, nil + } + // 列同理必须是纯字母:parseA1Cell 会给列名补 "1" 再解析,"A5" 会变成 "A51" + // 被当成 A 列静默放过。先要求 a/b 只含字母,杜绝带数字/尾随字符的输入通过预检; + // 纯多字母列名(如 "AX")是合法的,正常通过。 + if !isAllLetters(a) || !isAllLetters(b) { + return "", 0, fmt.Errorf("列范围非法: %s(应形如 \"A:C\")", addr) + } + // isAllLetters 已保证 a/b 为非空纯字母,补 "1" 后 parseA1Cell 必然成功,无需再判错。 + c1, _, _ := parseA1Cell(strings.ToUpper(a) + "1") + c2, _, _ := parseA1Cell(strings.ToUpper(b) + "1") + // 反序范围(如 "C:A")必须同时交换起始列,否则会静默改错目标: + // 只交换用于算长度的索引会得到 startIndex="C"/length=3,改到 C/D/E 而非 A/B/C。 + // 行分支同样在交换后返回较小的 r1。 + start = strings.ToUpper(a) + if c2 < c1 { + c1, c2 = c2, c1 + start = strings.ToUpper(b) + } + return start, c2 - c1 + 1, nil +} + +// parseCreateStyles 解析 --styles,校验结构;expectedNames 非空时校验数量/顺序/name 与 --sheets 一致。 +func parseCreateStyles(stylesStr string, expectedNames []string) ([]sheetStyleOps, error) { + var payload any + if err := json.Unmarshal([]byte(stylesStr), &payload); err != nil { + return nil, fmt.Errorf("--styles JSON 解析失败: %w", err) + } + // 接受 {"styles":[...]} 或直接数组 + var arrRaw []byte + switch v := payload.(type) { + case map[string]any: + inner, ok := v["styles"] + if !ok { + return nil, fmt.Errorf("--styles 对象形式必须含 styles 数组") + } + arrRaw, _ = json.Marshal(inner) + case []any: + arrRaw, _ = json.Marshal(v) + default: + return nil, fmt.Errorf("--styles 必须是 {\"styles\":[...]} 或 JSON 数组") + } + // 顶层键先按原始 map 查一遍:json.Unmarshal 只认 sheetStyleOps 的 tag,写成 + // cellStyles 的那份样式会被静默丢掉,同一项里的 row_sizes 却照常生效 —— 用户拿到 + // 的是一份「命令成功但样式不全」的表格,而 --styles 是建文档之后的非原子步骤。 + var rawItems []map[string]any + if err := json.Unmarshal(arrRaw, &rawItems); err != nil { + return nil, fmt.Errorf("--styles 解析失败: %w", err) + } + for i, raw := range rawItems { + if err := rejectUnknownFields(raw, "--styles 单项", styleItemFields); err != nil { + return nil, fmt.Errorf("--styles[%d]: %w", i, err) + } + } + var items []sheetStyleOps + if err := json.Unmarshal(arrRaw, &items); err != nil { + return nil, fmt.Errorf("--styles 解析失败: %w", err) + } + if len(items) == 0 { + return nil, fmt.Errorf("--styles 不能为空数组") + } + for i, it := range items { + if it.isEmpty() { + return nil, fmt.Errorf("--styles[%d] 至少需要 cell_styles / row_sizes / col_sizes / cell_merges 之一", i) + } + } + if len(expectedNames) > 0 { + // 与 --sheets 搭配:长度/顺序/name 必须一一对应(同飞书规则) + if len(items) != len(expectedNames) { + return nil, fmt.Errorf("--styles 项数(%d)必须与 --sheets 子表数(%d)一致且顺序对应", len(items), len(expectedNames)) + } + for i, it := range items { + if it.Name != expectedNames[i] { + return nil, fmt.Errorf("--styles[%d].name=%q 与 --sheets[%d].name=%q 不一致(需顺序对应)", i, it.Name, i, expectedNames[i]) + } + } + } else if len(items) != 1 { + // 与 --values 搭配:只接受一项(其 name 忽略) + return nil, fmt.Errorf("--values 搭配 --styles 时只能有 1 项(当前 %d 项)", len(items)) + } + return items, nil +} + +// styleCall 是一次待执行的 MCP 调用(先规划、后执行,便于前置校验)。 +type styleCall struct { + tool string + args map[string]any +} + +// planStyleOps 把 styles 单项翻译为待执行的 MCP 调用序列;纯函数、不发请求, +// 所有结构/枚举校验都在此完成,便于在创建文档**之前**快速失败。 +// 顺序:cell_styles → row_sizes → col_sizes → cell_merges(合并最后,避免干扰)。 +func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error) { + var calls []styleCall + + for i, item := range ops.CellStyles { + spec, rangeAddr, err := cellStyleItemToSpec(item) + if err != nil { + return nil, fmt.Errorf("cell_styles[%d]: %w", i, err) + } + rows, cols, err := parseA1Range(rangeAddr) + if err != nil { + return nil, fmt.Errorf("cell_styles[%d].range: %w", i, err) + } + cells, err := buildStyleCells(spec, rows, cols) + if err != nil { + return nil, fmt.Errorf("cell_styles[%d]: %w", i, err) + } + calls = append(calls, styleCall{"set_cell_range", map[string]any{ + "nodeId": nodeID, "sheetId": sheetID, "rangeAddress": rangeAddr, "cells": cells, + }}) + } + + planSizes := func(list []map[string]any, isRow bool, label string) error { + for i, item := range list { + if err := rejectUnknownFields(item, label+" 项", sizeItemFields); err != nil { + return fmt.Errorf("%s[%d]: %w", label, i, err) + } + rangeAddr := pickStr(item, "range") + if rangeAddr == "" { + return fmt.Errorf("%s[%d] 缺少必填的 range", label, i) + } + // type 对齐飞书:pixel(需 size)/ standard(恢复默认尺寸)/ auto(按内容自适应,仅行支持) + typ := strings.ToLower(pickStr(item, "type")) + if typ == "" { + typ = "pixel" + } + dim := "COLUMNS" + if isRow { + dim = "ROWS" + } + args := map[string]any{ + "nodeId": nodeID, "sheetId": sheetID, "dimension": dim, "sizeType": typ, + } + // type 枚举按维度区分(与飞书一致):row_sizes 有 auto,col_sizes 只有 pixel / standard + enumHint := "pixel / standard" + if isRow { + enumHint = "pixel / standard / auto" + } + switch { + case typ == "pixel": + size, ok, err := pickNum(item, "size") + if err != nil { + return fmt.Errorf("%s[%d].%w", label, i, err) + } + if !ok || size <= 0 { + return fmt.Errorf("%s[%d] type=pixel 时必须提供正整数 size", label, i) + } + args["pixelSize"] = size + case typ == "standard": + // 尺寸由服务端读取默认行高/列宽决定,无需 size。 + // 给了 size 说明调用方预期不符,静默忽略会让人以为生效了。 + _, ok, err := pickNum(item, "size") + if err != nil { + return fmt.Errorf("%s[%d].%w", label, i, err) + } + if ok { + return fmt.Errorf("%s[%d] type=standard 表示恢复默认尺寸,不能同时给 size;要指定固定像素请用 type=pixel", label, i) + } + case typ == "auto" && isRow: + // 行高按内容自适应,无需 size + _, ok, err := pickNum(item, "size") + if err != nil { + return fmt.Errorf("%s[%d].%w", label, i, err) + } + if ok { + return fmt.Errorf("%s[%d] type=auto 表示按内容自适应,不能同时给 size;要指定固定像素请用 type=pixel", label, i) + } + default: + return fmt.Errorf("%s[%d].type=%q 非法(合法值: %s)", label, i, typ, enumHint) + } + start, length, err := parseRowColRange(rangeAddr, isRow) + if err != nil { + return fmt.Errorf("%s[%d].range: %w", label, i, err) + } + args["startIndex"] = start + args["length"] = length + calls = append(calls, styleCall{"update_dimension", args}) + } + return nil + } + if err := planSizes(ops.RowSizes, true, "row_sizes"); err != nil { + return nil, err + } + if err := planSizes(ops.ColSizes, false, "col_sizes"); err != nil { + return nil, err + } + + for i, item := range ops.CellMerges { + if err := rejectUnknownFields(item, "cell_merges 项", mergeItemFields); err != nil { + return nil, fmt.Errorf("cell_merges[%d]: %w", i, err) + } + rangeAddr := pickStr(item, "range") + if rangeAddr == "" { + return nil, fmt.Errorf("cell_merges[%d] 缺少必填的 range", i) + } + // 严格解析合并区域地址,与 cell_styles 分支一致:planStyleOps 会在创建文档 + // **之前**被干跑一次做前置校验,非法 range(如 "not-a-range")必须在此拦下, + // 否则会走到建文档→写数据→应用前序样式,直到最后 merge_cells 才被服务端 + // 拒绝,留下无法回滚的部分完成文档。merge_cells 的 range 契约是 A1:B3 形态, + // parseA1Range 正好覆盖(并自动剥离 Sheet1! 前缀)。 + if _, _, err := parseA1Range(rangeAddr); err != nil { + return nil, fmt.Errorf("cell_merges[%d].range: %w", i, err) + } + mergeType, err := feishuMergeType(pickStr(item, "merge_type", "mergeType")) + if err != nil { + return nil, fmt.Errorf("cell_merges[%d]: %w", i, err) + } + calls = append(calls, styleCall{"merge_cells", map[string]any{ + "nodeId": nodeID, "sheetId": sheetID, "rangeAddress": rangeAddr, + "mergeType": mergeType, + }}) + } + return calls, nil +} + +// applyStyleOps 规划并顺序执行 styles 单项对应的 MCP 调用。 +func applyStyleOps(ctx context.Context, nodeID, sheetID string, ops sheetStyleOps) error { + calls, err := planStyleOps(nodeID, sheetID, ops) + if err != nil { + return err + } + for _, c := range calls { + if err := callMCPToolSilent(ctx, c.tool, c.args); err != nil { + return fmt.Errorf("%s 应用失败: %w", c.tool, err) + } + } + return nil +} + +// callMCPToolSilent 调用 MCP 工具但不打印结果(用于编排中的中间步骤)。 +func callMCPToolSilent(ctx context.Context, tool string, args map[string]any) error { + _, err := callMCPToolReturnText(ctx, tool, args) + return err +} + +// waitSheetWritable 等待新建文档进入可写状态。 +// 新建表格后服务端仍在初始化,此时写入可能返回成功但数据不落盘, +// 因此先用 get_all_sheets 探活(带退避重试),确认文档已就绪再写数据。 +func waitSheetWritable(ctx context.Context, nodeID string) error { + delays := []time.Duration{0, 500 * time.Millisecond, 1 * time.Second, 2 * time.Second, 3 * time.Second} + var lastErr error + for _, d := range delays { + if d > 0 { + // helperAfter 是测试时间缝(生产等价于 time.After); + // 与 wukong 一致,此处不响应 ctx 取消。 + <-helperAfter(d) + } + if _, err := resolveFirstSheetID(ctx, nodeID); err == nil { + return nil + } else { + lastErr = err + } + } + return lastErr +} + +// firstNonEmptyValuesCell 返回 values 里第一个非空单元格的 A1 地址(按行优先)。 +// 调用方已保证矩阵含至少一个非空格,所以不会退化到 "A1" 兜底之外的情况。 +func firstNonEmptyValuesCell(values [][]any) string { + for i, row := range values { + for j, cell := range row { + if cellToString(cell) != "" { + return fmt.Sprintf("%s%d", sheetColumnLetterFromZeroBased(j), i+1) + } + } + } + return "A1" +} + +// valuesHaveContent 判断二维数据里是否有任何非空单元格。 +// 全空矩阵(如 [["",""]])写不出任何内容,必须在建文档之前拒掉, +// 否则会白建一个空文档再由回读校验报成"写入未生效"。 +func valuesHaveContent(values [][]any) bool { + for _, row := range values { + for _, cell := range row { + if cellToString(cell) != "" { + return true + } + } + } + return false +} + +// verifyRangeNotEmpty 回读校验目标区域确实写入了数据(防"返回成功但未落盘")。 +// 返回 nil 表示已确认非空;无法确认时返回错误说明。 +func verifyRangeNotEmpty(ctx context.Context, nodeID, sheetID, rangeAddr string) error { + text, err := callMCPToolReturnText(ctx, "get_range_as_csv", map[string]any{ + "nodeId": nodeID, "sheetId": sheetID, "range": rangeAddr, + }) + if err != nil { + return fmt.Errorf("回读校验失败: %w", err) + } + data := unwrapSheetResult(text) + if data == nil { + return fmt.Errorf("回读校验失败:无法解析返回") + } + csvText, _ := data["csv"].(string) + // 去掉 [row=N] 前缀与分隔符后若无任何实质字符,视为未落盘 + stripped := strings.NewReplacer(",", "", "\n", "", " ", "").Replace( + regexpRowPrefix.ReplaceAllString(csvText, "")) + if strings.TrimSpace(stripped) == "" { + return fmt.Errorf("数据未落盘(回读为空)") + } + return nil +} + +// regexpRowPrefix 匹配 csv-get 的 [row=N] 行号前缀。 +var regexpRowPrefix = regexp.MustCompile(`\[row=\d+\]\s*`) + +// writeValuesToSheet 把二维数据转 CSV 后写入指定工作表(复用 set_range_from_csv,允许覆盖)。 +func writeValuesToSheet(ctx context.Context, nodeID, sheetID string, values [][]any) error { + if len(values) == 0 { + return nil + } + return callMCPToolSilent(ctx, "set_range_from_csv", map[string]any{ + "nodeId": nodeID, + "sheetId": sheetID, + "csv": valuesToCSV(values), + "startCell": "A1", + "allowOverwrite": true, + }) +} + +// valuesToCSV 把二维数据编码为 RFC4180 CSV 文本(每个单元格转字符串,nil 视为空)。 +func valuesToCSV(values [][]any) string { + var buf bytes.Buffer + w := csv.NewWriter(&buf) + for _, row := range values { + rec := make([]string, len(row)) + for i, cell := range row { + rec[i] = cellToString(cell) + } + _ = w.Write(rec) + } + w.Flush() + return buf.String() +} + +func cellToString(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case bool: + return strconv.FormatBool(t) + case json.Number: + // 原样输出字面量,避免任何浮点中转造成的精度损失 + return t.String() + case float64: + // 整数不带小数点 + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + default: + return fmt.Sprintf("%v", t) + } +} + +// parseCreatedNodeID 从 create_workspace_sheet 响应提取 nodeId。 +func parseCreatedNodeID(text string) (string, error) { + data := unwrapSheetResult(text) + if data == nil { + return "", fmt.Errorf("解析创建结果失败,响应: %s", text) + } + if nodeID, _ := data["nodeId"].(string); nodeID != "" { + return nodeID, nil + } + return "", fmt.Errorf("创建结果未返回 nodeId,响应: %s", text) +} + +// resolveFirstSheetID 通过 get_all_sheets 获取第一个工作表的 sheetId。 +func resolveFirstSheetID(ctx context.Context, nodeID string) (string, error) { + text, err := callMCPToolReturnText(ctx, "get_all_sheets", map[string]any{"nodeId": nodeID}) + if err != nil { + return "", err + } + data := unwrapSheetResult(text) + if data == nil { + return "", fmt.Errorf("解析工作表列表失败,响应: %s", text) + } + sheets, _ := data["sheets"].([]any) + if len(sheets) == 0 { + return "", fmt.Errorf("表格中未找到任何工作表") + } + first, _ := sheets[0].(map[string]any) + if id, _ := first["sheetId"].(string); id != "" { + return id, nil + } + return "", fmt.Errorf("工作表列表未返回 sheetId,响应: %s", text) +} + +// resolveSheetIDsByName 返回文档内 name→sheetId 映射,用于 table_put 之后按 +// 工作表名逐一回读校验(table_put 会按 name 复用/新建工作表)。 +func resolveSheetIDsByName(ctx context.Context, nodeID string) (map[string]string, error) { + text, err := callMCPToolReturnText(ctx, "get_all_sheets", map[string]any{"nodeId": nodeID}) + if err != nil { + return nil, err + } + data := unwrapSheetResult(text) + if data == nil { + return nil, fmt.Errorf("解析工作表列表失败,响应: %s", text) + } + sheets, _ := data["sheets"].([]any) + m := make(map[string]string, len(sheets)) + for _, s := range sheets { + sm, _ := s.(map[string]any) + name, _ := sm["name"].(string) + id, _ := sm["sheetId"].(string) + if name != "" && id != "" { + m[name] = id + } + } + return m, nil +} + +// sheetSpecGrid 把 table_put 的一个 sheet spec 还原成写入的二维逻辑网格: +// 表头行(写表头时)在前,其后接 data 行。用于定位首个预期非空单元格。 +// spec 已由 validateCreateSheetSpec 校验,columns 必为字符串数组、data 行必为数组。 +func sheetSpecGrid(spec map[string]any) [][]any { + var grid [][]any + if cols, ok := spec["columns"].([]any); ok && len(cols) > 0 && specWritesHeader(spec) { + grid = append(grid, cols) + } + if data, ok := spec["data"].([]any); ok { + for _, r := range data { + if row, ok := r.([]any); ok { + grid = append(grid, row) + } + } + } + return grid +} + +// firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。 +// 尊重起始格偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 +// 的内容(例如 header=false 且没给 data),这类工作表本就应为空,调用方跳过回读, +// 避免把合法的空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell +// 一致:不能死盯 A1,首行/首列为空的合法数据不应被误报。 +// +// 起始格键名只认 table_put 线上契约的 camelCase startCell —— snake_case 的 +// start_cell 会被服务端 DTO 静默丢弃(写入实际落在 A1),探针若认它就会指到空白 +// 处、把成功的写入报成「数据未落盘」;validateCreateSheetSpec 已在前置校验里拒掉。 +func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) { + col0, row0 := 1, 1 + if sc := normalizeSpecStartCell(pickStr(spec, "startCell")); sc != "" { + if c, r, err := parseA1Cell(sc); err == nil { + col0, row0 = c, r + } + } + // mode=append 时服务端按「已有数据的下一行」定位:本命令新建的工作表 + // 都是空表,追加就从第 1 行开始,startCell 的行号被忽略(列号仍生效)。 + if mode, _ := spec["mode"].(string); mode == "append" { + row0 = 1 + } + for i, row := range sheetSpecGrid(spec) { + for j, cell := range row { + if cellToString(cell) != "" { + return fmt.Sprintf("%s%d", sheetColumnLetterFromZeroBased(col0-1+j), row0+i), true + } + } + } + return "", false +} + +// unwrapSheetResult 解析 MCP 响应 JSON,自动剥离外层 result 包装。 +func unwrapSheetResult(text string) map[string]any { + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + return nil + } + if result, ok := data["result"].(map[string]any); ok { + return result + } + return data +} diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go new file mode 100644 index 00000000..39e1a4aa --- /dev/null +++ b/internal/helpers/sheet_create_with_data_test.go @@ -0,0 +1,1623 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "io" + "math" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// installSheetProductArgs 让 callMCPToolReturnText 能把工具解析到 sheet server +// (产品解析读 os.Args,与其他 sheet 测试一致)。 +func installSheetProductArgs(t *testing.T) { + t.Helper() + testseam.Swap(t, &os.Args, []string{"dws", "sheet"}) +} + +// runCreate 用脚本化的 MCP 响应驱动一次 sheet create-with-data。 +func runCreate(t *testing.T, caller *scriptedToolCaller, flags map[string]string) error { + t.Helper() + installScriptedCaller(t, caller) + installImmediateTiming(t) + installSheetProductArgs(t) + cmd := newSheetCreateWithDataCmd() + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return cmd.RunE(cmd, nil) +} + +// createOKSteps 是「建表 → 探活/定位默认表 → 写数据 → 回读校验」的成功响应序列。 +// resolveFirstSheetID 在 waitSheetWritable 和随后的定位里各调一次 get_all_sheets。 +func createOKSteps(csvReadback string) []scriptedToolStep { + return []scriptedToolStep{ + {text: `{"nodeId":"NODE_1","docUrl":"https://alidocs.test/i/nodes/NODE_1"}`}, // create_workspace_sheet + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, // waitSheetWritable 探活 + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, // resolveFirstSheetID + {text: `{"success":true}`}, // set_range_from_csv + {text: `{"csv":"` + csvReadback + `"}`}, // get_range_as_csv 回读 + } +} + +// sheet create 必须保持「一次 create_workspace_sheet」不变(它的叶子契约就是单次 +// RPC);带初始数据的编排在 sheet create-with-data 上,两条命令的职责不能混。 +func TestSheetCreateStaysSingleCallAndHasNoDataFlags(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"nodeId":"N"}`}}} + installScriptedCaller(t, caller) + installSheetProductArgs(t) + + var createCmd *cobra.Command + for _, cmd := range newWorkbookCmds() { + if cmd.Name() == "create" { + createCmd = cmd + break + } + } + if createCmd == nil { + t.Fatal("create command not found in newWorkbookCmds()") + } + for _, name := range []string{"values", "sheets", "styles"} { + if createCmd.Flags().Lookup(name) != nil { + t.Errorf("sheet create 不应绑定 --%s(编排属于 sheet create-with-data)", name) + } + } + if err := createCmd.Flags().Set("name", "空表"); err != nil { + t.Fatalf("set --name: %v", err) + } + if err := createCmd.RunE(createCmd, nil); err != nil { + t.Fatalf("create: %v", err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1 (sheet create 只调 create_workspace_sheet)", caller.calls) + } + if caller.tool != "create_workspace_sheet" { + t.Fatalf("tool = %q", caller.tool) + } +} + +func TestSheetCreateRejectsConflictingAndOrphanFlags(t *testing.T) { + cases := []struct { + name string + flags map[string]string + want string + }{ + { + name: "no-data-at-all", + flags: map[string]string{"name": "X"}, + want: "--values 与 --sheets 必须给一个", + }, + { + name: "styles-without-data", + flags: map[string]string{"name": "X", "styles": `{"styles":[{"name":"S","cell_merges":[{"range":"A1:B1"}]}]}`}, + want: "--values 与 --sheets 必须给一个", + }, + { + name: "values-and-sheets", + flags: map[string]string{"name": "X", "values": `[[1]]`, "sheets": `[{"name":"S"}]`}, + want: "二选一", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runCreate(t, caller, tc.flags) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0 (参数冲突必须在发请求前失败)", caller.calls) + } + }) + } +} + +// 关键不变量:所有 JSON/枚举校验都必须在 create_workspace_sheet 之前完成, +// 否则会留下一份白建的空文档。 +func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { + cases := []struct { + name string + flags map[string]string + want string + }{ + {"values-bad-json", map[string]string{"name": "X", "values": `[[`}, "--values JSON 解析失败"}, + // Decode 只读一个值就返回:粘贴多了一段/shell 拼接残留会静默丢掉后半截, + // 却照常建出文档。必须要求读到 EOF。 + {"values-trailing-data", map[string]string{"name": "X", "values": `[[1]] trailing-data`}, "--values 只接受单个 JSON 值,末尾有多余内容"}, + {"values-two-json-values", map[string]string{"name": "X", "values": `[["a"]][["b"]]`}, "--values 只接受单个 JSON 值,末尾有多余内容"}, + // `null` 是合法 JSON 但解析出 nil slice:判据若用 values != nil 会误入 + // --sheets 分支并对空 sheetSpecs 取 [0] panic。 + {"values-null", map[string]string{"name": "X", "values": `null`}, "--values 不能为空"}, + // `[]` 一个字节都写不出,若放过去会白建空文档再报成"写入未生效"。 + {"values-empty-array", map[string]string{"name": "X", "values": `[]`}, "--values 不能为空"}, + // 全空矩阵写不出任何数据,同样必须在建文档之前拒掉 + {"values-all-cells-empty", map[string]string{"name": "X", "values": `[["",""],["",""]]`}, "--values 全部单元格为空"}, + {"sheets-bad-json", map[string]string{"name": "X", "sheets": `{`}, "--sheets JSON 解析失败"}, + {"sheets-trailing-data", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"]}] trailing-data`}, "--sheets 只接受单个 JSON 值,末尾有多余内容"}, + {"sheets-two-json-values", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"]}][{"name":"二月","columns":["a"]}]`}, "--sheets 只接受单个 JSON 值,末尾有多余内容"}, + {"sheets-wrong-type", map[string]string{"name": "X", "sheets": `"str"`}, "必须是 JSON 数组"}, + {"sheets-empty", map[string]string{"name": "X", "sheets": `[]`}, "不能为空数组"}, + {"sheets-item-not-object", map[string]string{"name": "X", "sheets": `[1]`}, "不是对象"}, + {"sheets-missing-name", map[string]string{"name": "X", "sheets": `[{"columns":["a"]}]`}, "缺少必填的 name"}, + // 重名工作表:table_put 会建出两张同名表,之后 --styles 按名称定位会落到 + // 不确定的那一张。--styles 是建文档后的非原子步骤,必须前置拒绝。 + {"sheets-duplicate-name", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"]},{"name":"一月","columns":["a"]}]`}, `--sheets[1].name="一月" 与 --sheets[0] 重复`}, + {"sheets-name-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":1,"columns":["a"]}]`}, "--sheets[0].name 必须是字符串"}, + {"sheets-name-blank", map[string]string{"name": "X", "sheets": `[{"name":" ","columns":["a"]}]`}, "--sheets[0] 缺少必填的 name"}, + // 畸形包装:出现 sheets 键就必须按包装对象处理,不能退化成"整个对象是单个 spec", + // 否则 {"sheets":"bad","name":"一月"} 会白建一份只有名字的文档。 + {"sheets-wrapper-not-array", map[string]string{"name": "X", "sheets": `{"sheets":"bad","name":"一月"}`}, "--sheets.sheets 必须是数组,实际是 string"}, + // 以下都是 table_put 的输入契约校验:类型/枚举错误若放过去,会先建文档、 + // 重命名,再在 table_put 阶段失败,留下用户并未请求到的半成品文档。 + {"spec-data-not-array", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"data":{"bad":1}}]`}, "--sheets[0]: data 必须是二维数组,实际是 map[string]interface {}"}, + {"spec-data-row-not-array", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"data":[5]}]`}, "--sheets[0]: data[0] 必须是数组,实际是 json.Number"}, + {"spec-data-row-width-mismatch", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a","b"],"data":[["x"]]}]`}, "--sheets[0]: data[0] 有 1 列,与 columns 的 2 列不一致"}, + {"spec-data-cell-not-scalar", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"data":[[{"x":1}]]}]`}, "--sheets[0]: data[0][0] 必须是字符串/数字/布尔/null"}, + {"spec-missing-columns", map[string]string{"name": "X", "sheets": `[{"name":"一月","data":[["x"]]}]`}, "--sheets[0]: 缺少必填的 columns"}, + {"spec-columns-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":"a"}]`}, "--sheets[0]: columns 必须是字符串数组,实际是 string"}, + {"spec-columns-empty", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":[]}]`}, "--sheets[0]: columns 不能为空数组"}, + {"spec-columns-item-not-string", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":[1]}]`}, "--sheets[0]: columns[0] 必须是字符串,实际是 json.Number"}, + {"spec-columns-item-blank", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"," "]}]`}, "--sheets[0]: columns[1] 不能为空字符串"}, + // 服务端按 trim 后的列名判重,本地必须用同一套归一化,否则 ["量","量 "] 只会 + // 在 table_put 阶段被拒。 + {"spec-columns-duplicate", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["量","量 "]}]`}, `--sheets[0]: columns[1]="量" 与 columns[0] 重复`}, + {"spec-dtypes-not-object", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"dtypes":["int64"]}]`}, "--sheets[0]: dtypes 必须是对象"}, + {"spec-dtypes-value-not-string", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"dtypes":{"a":1}}]`}, `--sheets[0]: dtypes["a"] 必须是字符串`}, + // dtypes/formats 的键按列名查表,写错既不报错也不生效——静默失效比报错更难发现。 + {"spec-dtypes-unknown-column", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"dtypes":{"b":"int64"}}]`}, `--sheets[0]: dtypes 的键 "b" 不是 columns 中的列名`}, + {"spec-formats-not-object", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"formats":"0.00"}]`}, "--sheets[0]: formats 必须是对象"}, + {"spec-cell-styles-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"cellStyles":"bold"}]`}, "--sheets[0]: cellStyles 必须是对象或二维数组"}, + {"spec-mode-invalid", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"mode":"upsert"}]`}, `--sheets[0]: mode="upsert" 非法(合法值: overwrite / append)`}, + {"spec-mode-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"mode":true}]`}, "--sheets[0]: mode 必须是字符串,实际是 bool"}, + {"spec-header-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"header":"yes"}]`}, "--sheets[0]: header 必须是布尔值,实际是 string"}, + {"spec-allow-overwrite-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"allowOverwrite":"yes"}]`}, "--sheets[0]: allowOverwrite 必须是布尔值"}, + {"spec-start-cell-wrong-type", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"startCell":3}]`}, "--sheets[0]: startCell 必须是字符串,实际是 json.Number"}, + // 非法 startCell 以前被静默忽略:写入按 A1 落盘,回读探针却按错位地址找, + // 成功的写入会被报成"数据未落盘"。 + {"spec-start-cell-invalid", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"startCell":"A1:B2"}]`}, `--sheets[0]: startCell="A1:B2" 不是合法的单元格地址`}, + // 小写与 $ 绝对引用是服务端接受的写法,不能误拒(见 normalizeSpecStartCell) + {"spec-start-cell-absolute-accepted", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"startCell":"$b$2","data":{"bad":1}}]`}, "data 必须是二维数组"}, + // 拼错的字段会被服务端 DTO 静默丢弃:datas 会写出"只有表头"的表,而探针刚好 + // 落在表头上,整条命令报成功——静默丢数据必须前置拒绝。 + {"spec-unknown-field", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"datas":[["x"]]}]`}, `--sheets[0]: 未知字段 "datas"`}, + {"spec-snake-case-field", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"start_cell":"B2"}]`}, `--sheets[0]: 字段 "start_cell" 拼写不符合契约,应为 "startCell"`}, + // 文档此刻还不存在,任何 sheetId 都不可能属于它;而重命名默认表和回读校验 + // 都按 name 定位,服务端却优先用 sheetId。 + {"spec-sheet-id-rejected", map[string]string{"name": "X", "sheets": `[{"name":"一月","columns":["a"],"sheetId":"S1"}]`}, "--sheets[0]: 不支持 sheetId"}, + {"styles-bad-json", map[string]string{"name": "X", "values": `[[1]]`, "styles": `{`}, "--styles JSON 解析失败"}, + {"styles-object-without-styles-key", map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"a":1}`}, "必须含 styles 数组"}, + {"styles-wrong-type", map[string]string{"name": "X", "values": `[[1]]`, "styles": `"s"`}, `必须是 {"styles":[...]}`}, + {"styles-empty", map[string]string{"name": "X", "values": `[[1]]`, "styles": `[]`}, "不能为空数组"}, + {"styles-item-empty", map[string]string{"name": "X", "values": `[[1]]`, "styles": `[{"name":"S"}]`}, "至少需要 cell_styles"}, + { + "styles-count-mismatch-with-sheets", + map[string]string{"name": "X", "sheets": `[{"name":"A","columns":["a"]},{"name":"B","columns":["b"]}]`, "styles": `[{"name":"A","cell_merges":[{"range":"A1:B1"}]}]`}, + "必须与 --sheets 子表数", + }, + { + "styles-name-mismatch-with-sheets", + map[string]string{"name": "X", "sheets": `[{"name":"A","columns":["a"]}]`, "styles": `[{"name":"Z","cell_merges":[{"range":"A1:B1"}]}]`}, + "不一致(需顺序对应)", + }, + { + "styles-multiple-with-values", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `[{"name":"A","cell_merges":[{"range":"A1"}]},{"name":"B","cell_merges":[{"range":"A1"}]}]`}, + "只能有 1 项", + }, + { + "cell-styles-missing-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"font_weight":"bold"}]}]}`}, + "cell_styles 项缺少必填的 range", + }, + { + "cell-styles-bad-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"zz","font_weight":"bold"}]}]}`}, + "无效单元格地址", + }, + { + "cell-styles-no-style-field", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1:B2"}]}]}`}, + "至少需要指定一个样式参数", + }, + { + "row-sizes-missing-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"type":"pixel","size":20}]}]}`}, + "row_sizes[0] 缺少必填的 range", + }, + { + "row-sizes-pixel-without-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:2","type":"pixel"}]}]}`}, + "type=pixel 时必须提供正整数 size", + }, + { + // JSON 数字都是 float64,直接 int(n) 会把 12.9 悄悄写成 12。 + // --styles 不可原子回滚,静默取整会留下与配置不符的表格。 + "cell-styles-fractional-font-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","font_size":12.9}]}]}`}, + "font_size=12.9 必须是整数", + }, + { + "row-sizes-fractional-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:1","type":"pixel","size":28.5}]}]}`}, + "size=28.5 必须是整数", + }, + { + // 1e20 以前被 int() 截成 math.MaxInt64 后照样下发 + "row-sizes-overflow-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:1","type":"pixel","size":1e20}]}]}`}, + "超出取值范围", + }, + { + "col-sizes-fractional-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","col_sizes":[{"range":"A:A","type":"pixel","size":120.5}]}]}`}, + "size=120.5 必须是整数", + }, + { + "row-sizes-standard-with-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:2","type":"standard","size":28}]}]}`}, + "type=standard 表示恢复默认尺寸,不能同时给 size", + }, + { + // standard / auto 分支也要先报出数值本身的问题,而不是笼统的 + // "不能同时给 size":小数是配置写错,提示必须指向 size 才能让调用方 + // 知道该改哪里。 + "row-sizes-standard-with-fractional-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:2","type":"standard","size":28.5}]}]}`}, + "size=28.5 必须是整数", + }, + { + "row-sizes-auto-with-fractional-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:2","type":"auto","size":28.5}]}]}`}, + "size=28.5 必须是整数", + }, + { + "row-sizes-auto-with-size", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:2","type":"auto","size":28}]}]}`}, + "type=auto 表示按内容自适应,不能同时给 size", + }, + { + "row-sizes-bad-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"a:b","type":"standard"}]}]}`}, + "行范围非法", + }, + { + // 列宽不提供 auto(与飞书 col_sizes 的 type 枚举一致) + "col-sizes-auto-rejected", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","col_sizes":[{"range":"A:B","type":"auto"}]}]}`}, + "col_sizes[0].type=\"auto\" 非法(合法值: pixel / standard)", + }, + { + "col-sizes-bad-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","col_sizes":[{"range":"1:2","type":"standard"}]}]}`}, + "列范围非法", + }, + { + "cell-merges-missing-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_merges":[{"merge_type":"all"}]}]}`}, + "cell_merges[0] 缺少必填的 range", + }, + { + // merge_type 未知值以前会原样透传,直到 merge_cells 才被服务端拒绝, + // 留下一个已建且已部分修改的文档。必须在建文档之前拦住。 + "cell-merges-invalid-merge-type", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_merges":[{"range":"A1:B1","merge_type":"invalid"}]}]}`}, + `cell_merges[0]: merge_type 非法: "invalid"`, + }, + { + // 非法合并区域地址同样只在最后 merge_cells 才被拒,会留下部分完成文档。 + // 必须在建文档之前严格解析拦下(与 cell_styles 的 range 校验一致)。 + "cell-merges-invalid-range", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_merges":[{"range":"not-a-range"}]}]}`}, + "cell_merges[0].range", + }, + { + // json.Unmarshal 只认 sheetStyleOps 的 tag:cellStyles 那份样式会被静默 + // 丢弃、同项的 row_sizes 却照常生效,留下一份"命令成功但样式不全"的表格。 + "styles-top-level-camel-case-key", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `[{"name":"S","cellStyles":[{"range":"A1","font_weight":"bold"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}]`}, + `--styles[0]: 字段 "cellStyles" 拼写不符合契约,应为 "cell_styles"`, + }, + { + // 键名对但值类型不对:结构化解码必须报错而不是被前一步的 map 解码放过 + "styles-list-value-wrong-type", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `[{"name":"S","cell_styles":"A1"}]`}, + "--styles 解析失败", + }, + { + "styles-top-level-unknown-key", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `[{"name":"S","cell_merges":[{"range":"A1:B1"}],"borders":[]}]`}, + `--styles[0]: 未知字段 "borders"(--styles 单项只接受 name / cell_styles / row_sizes / col_sizes / cell_merges)`, + }, + { + // 内层键同理:pickStr 认不出的拼写会被丢掉,加粗生效而背景色悄悄丢失。 + // background_color / backgroundColor / bgColor 三种拼写才是被接受的。 + "cell-styles-near-miss-key", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","font_weight":"bold","backgroundcolor":"#FFF"}]}]}`}, + `cell_styles[0]: 字段 "backgroundcolor" 拼写不符合契约,应为 "background_color"`, + }, + { + "cell-styles-unknown-key", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","font_weight":"bold","italic":true}]}]}`}, + `cell_styles[0]: 未知字段 "italic"(cell_styles 项只接受 range / background_color /`, + }, + { + // border_styles 的边对象只认 style / color:写错的键此前被静默忽略, + // 会画出一条没有颜色的边框而命令报成功。create 路径同样必须在建文档前拒掉。 + "cell-styles-border-unknown-edge-field", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","border_styles":{"bottom":{"style":"medium","colour":"#f00"}}}]}]}`}, + `--border-styles-json.bottom: 未知字段 "colour"`, + }, + { + "cell-styles-border-color-not-string", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","borderStyles":{"top":{"style":"solid","color":123}}}]}]}`}, + "--border-styles-json.top.color 必须是字符串,实际是 float64", + }, + { + // camelCase 别名必须照常放行(不能把兼容写法当成拼错) + "cell-styles-camel-case-alias-accepted", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","backgroundColor":"#FFF","hAlign":"center","borderStyles":{"bottom":{"style":"medium"}},"fontSize":"x"}]}]}`}, + "cell_styles.fontSize 必须是数字", // 只有 fontSize 的类型错误应被报出,其余别名均已接受 + }, + { + "row-sizes-unknown-key", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:1","type":"pixel","size":28,"height":9}]}]}`}, + `row_sizes[0]: 未知字段 "height"(row_sizes 项只接受 range / type / size)`, + }, + { + "cell-merges-unknown-key", + map[string]string{"name": "X", "values": `[[1]]`, "styles": `{"styles":[{"name":"S","cell_merges":[{"range":"A1:B1","merge_style":"all"}]}]}`}, + `cell_merges[0]: 未知字段 "merge_style"`, + }, + { + // 嵌套对象/数组会被 cellToString 写成 "map[a:1]" 这类无意义文本, + // 而回读只校验非空,于是垃圾数据被报成写入成功。 + "values-cell-not-scalar", + map[string]string{"name": "X", "values": `[[1,{"a":1}]]`}, + "--values[0][1] 必须是字符串/数字/布尔/null", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runCreate(t, caller, tc.flags) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0 (校验必须早于建文档,否则会留下白建的空表)", caller.calls) + } + }) + } +} + +// 写入规模上限是服务端硬限(table_put 30000 单元格;set_range_from_csv 30000 +// 单元格 / 2M 字符)。超限若不前置拦下,同样是先建好文档再在写入阶段失败。 +func TestSheetCreateRejectsOversizedWriteBeforeCreatingDocument(t *testing.T) { + repeatRow := func(row string, n int) string { + rows := make([]string, n) + for i := range rows { + rows[i] = row + } + return "[" + strings.Join(rows, ",") + "]" + } + cases := []struct { + name string + flags map[string]string + want string + }{ + { + // 30000 数据行 + 1 表头行 = 30001 > 30000 + "sheets-cells-over-cap", + map[string]string{"name": "X", "sheets": `[{"name":"S","columns":["a"],"data":` + repeatRow(`[1]`, 30000) + `}]`}, + "单个工作表写入单元格总数上限为 30000(当前 30001 行 × 1 列 = 30001)", + }, + { + // header=false 少写一行,刚好 30000 应放行 —— 见下方 accepted 断言 + "sheets-cells-over-cap-multi-column", + map[string]string{"name": "X", "sheets": `[{"name":"S","columns":["a","b"],"data":` + repeatRow(`[1,2]`, 15000) + `}]`}, + "当前 15001 行 × 2 列 = 30002", + }, + { + "values-cells-over-cap", + map[string]string{"name": "X", "values": repeatRow(`[1]`, 30001)}, + "--values 单元格总数上限为 30000(当前 30001 行 × 1 列 = 30001)", + }, + { + // 字符上限按 rune 计:中文按字符数而不是字节数,避免误拒 2/3 长度的中文表 + "values-chars-over-cap", + map[string]string{"name": "X", "values": `[["` + strings.Repeat("中", maxCSVWriteChars) + `"]]`}, + "编码为 CSV 后长度为 2000001 字符,超过上限 2000000", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runCreate(t, caller, tc.flags) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0(超限必须早于建文档)", caller.calls) + } + }) + } + + // 恰好等于上限不该被拒(header=false 时不占行) + atCap := `[{"name":"S","columns":["a"],"header":false,"data":` + repeatRow(`[1]`, maxTableWriteCells) + `}]` + if _, err := parseCreateSheetSpecs(atCap); err != nil { + t.Fatalf("恰好 %d 个单元格被误拒: %v", maxTableWriteCells, err) + } +} + +func TestSheetCreateWithValuesWritesAndVerifies(t *testing.T) { + caller := &scriptedToolCaller{steps: createOKSteps("[row=1]姓名,分数")} + if err := runCreate(t, caller, map[string]string{"name": "名单", "values": `[["姓名","分数"],["张三",90]]`}); err != nil { + t.Fatalf("create with values: %v", err) + } + // create → 探活 → 定位默认表 → 写 csv → 回读 + if caller.calls != 5 { + t.Fatalf("calls = %d, want 5", caller.calls) + } + if caller.tool != "get_range_as_csv" { + t.Fatalf("last tool = %q, want get_range_as_csv (回读校验)", caller.tool) + } +} + +func TestSheetCreateWithValuesDryRunNeverCallsRemote(t *testing.T) { + caller := &scriptedToolCaller{dry: true} + if err := runCreate(t, caller, map[string]string{"name": "X", "values": `[[1]]`}); err != nil { + t.Fatalf("dry run: %v", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0", caller.calls) + } +} + +func TestSheetCreateWithSheetsRenamesDefaultThenTablePut(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, // update_sheet 重命名默认表 + {text: `{"success":true}`}, // table_put + // 回读校验阶段:先取 name→sheetId,再逐表回读 + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"},{"sheetId":"SHEET_2","name":"二月"}]}`}, + {text: `{"csv":"[row=1] 项目\n"}`}, // 一月 回读非空 + {text: `{"csv":"[row=1] 项目\n"}`}, // 二月 回读非空 + }} + if err := runCreate(t, caller, map[string]string{ + "name": "报表", + "sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]},{"name":"二月","columns":["项目"],"data":[["房租"]]}]`, + }); err != nil { + t.Fatalf("create with sheets: %v", err) + } + // create → 探活 → 定位默认表 → 重命名 → table_put → 取 name 映射 → 两表各回读一次 + if caller.calls != 8 { + t.Fatalf("calls = %d, want 8", caller.calls) + } +} + +// table_put 返回成功但某工作表回读为空(新建文档初始化竞态),必须报数据未落盘, +// 而不是静默成功。错误须带 nodeId 与补写指引。 +func TestSheetCreateWithSheetsVerifiesEachSheetLanded(t *testing.T) { + // 二月回读为空 → 报错 + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, + {text: `{"success":true}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"},{"sheetId":"SHEET_2","name":"二月"}]}`}, + {text: `{"csv":"[row=1] 项目\n"}`}, // 一月 非空 + {text: `{"csv":""}`}, // 二月 回读为空 + }} + err := runCreate(t, caller, map[string]string{ + "name": "报表", + "sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]},{"name":"二月","columns":["项目"],"data":[["房租"]]}]`, + }) + if err == nil { + t.Fatal("二月回读为空应报错,而非静默成功") + } + for _, want := range []string{"NODE_1", "二月", "写入未生效", "table-put"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("错误信息缺少 %q: %v", want, err) + } + } + + // table_put 后工作表列表里找不到某个 spec 名 → 同样报错 + missing := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, + {text: `{"success":true}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"}]}`}, // 缺二月 + {text: `{"csv":"[row=1] 项目\n"}`}, // 一月 回读非空,随后二月查找失败 + }} + err = runCreate(t, missing, map[string]string{ + "name": "报表", + "sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]},{"name":"二月","columns":["项目"],"data":[["房租"]]}]`, + }) + if err == nil || !strings.Contains(err.Error(), "二月") || !strings.Contains(err.Error(), "NODE_1") { + t.Fatalf("缺失工作表应带 nodeId 报错: %v", err) + } + + // header=false 且没给 data 的工作表本就应为空,不回读、不报错 + empty := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, + {text: `{"success":true}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"有数据"},{"sheetId":"SHEET_2","name":"空表"}]}`}, + {text: `{"csv":"[row=1] a\n"}`}, // 有数据 回读非空;空表不回读 + }} + if err := runCreate(t, empty, map[string]string{ + "name": "报表", + "sheets": `[{"name":"有数据","columns":["a"],"data":[["v"]]},{"name":"空表","columns":["a"],"header":false}]`, + }); err != nil { + t.Fatalf("无数据工作表不应触发回读失败: %v", err) + } + if empty.calls != 7 { + t.Fatalf("calls = %d, want 7(空表不回读,只 6 步编排 + 有数据表 1 次回读)", empty.calls) + } +} + +func TestFirstNonEmptySheetSpecCell(t *testing.T) { + mustDecode := func(s string) map[string]any { + var m map[string]any + if err := json.Unmarshal([]byte(s), &m); err != nil { + t.Fatal(err) + } + return m + } + for _, tc := range []struct { + spec string + wantCell string + wantContent bool + }{ + {`{"name":"S","columns":["项目"],"data":[["房租"]]}`, "A1", true}, + {`{"name":"S","columns":["",""],"data":[["","x"]]}`, "B2", true}, // 首列表头空 + {`{"name":"S","columns":["id"],"data":[[1]],"startCell":"C3"}`, "C3", true}, // startCell(table_put 线上 camelCase) + // 服务端解析前会 toUpperCase 并允许 $ 绝对引用,探针须同样解释 + {`{"name":"S","columns":["id"],"data":[[1]],"startCell":"$c$3"}`, "C3", true}, + // header=false 时服务端不写表头行,探针必须从 data 首行算起, + // 否则会盯着一个永远不会被写入的表头位置报"数据未落盘"。 + {`{"name":"S","columns":["id"],"data":[[1]]}`, "A1", true}, + {`{"name":"S","columns":["id"],"data":[[1]],"header":false}`, "A1", true}, + {`{"name":"S","columns":["id"],"data":[["",""],["x"]],"header":false}`, "A2", true}, + {`{"name":"S","columns":["id"],"header":false}`, "", false}, // 不写表头又没有 data + // mode=append 落在新建的空表上,服务端从第 1 行开始写:startCell 的行号被忽略, + // 列号仍生效。探针若照搬 startCell 会指到空白处。 + {`{"name":"S","columns":["id"],"data":[[1]],"mode":"append","startCell":"C7"}`, "C1", true}, + {`{"name":"S","columns":[],"data":[]}`, "", false}, // 空 + } { + gotCell, gotContent := firstNonEmptySheetSpecCell(mustDecode(tc.spec)) + if gotCell != tc.wantCell || gotContent != tc.wantContent { + t.Errorf("firstNonEmptySheetSpecCell(%s) = (%q,%v), want (%q,%v)", tc.spec, gotCell, gotContent, tc.wantCell, tc.wantContent) + } + } +} + +// resolveSheetIDsByName 的失败路径:RPC 出错、以及响应无法解析为对象。 +func TestResolveSheetIDsByNameFailures(t *testing.T) { + installSheetProductArgs(t) + rpcErr := &scriptedToolCaller{steps: []scriptedToolStep{{err: fmt.Errorf("boom")}}} + installScriptedCaller(t, rpcErr) + if _, err := resolveSheetIDsByName(context.Background(), "N"); err == nil { + t.Fatal("RPC 出错时应返回 error") + } + + bad := &scriptedToolCaller{steps: []scriptedToolStep{{text: `not-json`}}} + installScriptedCaller(t, bad) + if _, err := resolveSheetIDsByName(context.Background(), "N"); err == nil || + !strings.Contains(err.Error(), "解析工作表列表失败") { + t.Fatalf("响应不可解析时应报解析失败: %v", err) + } +} + +// create-with-data --sheets 时 table_put 后取工作表列表失败,须带 nodeId 报错而非静默成功。 +func TestSheetCreateWithSheetsReadbackListFailureSurfacesNodeID(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, + {text: `{"success":true}`}, + {err: fmt.Errorf("list boom")}, // resolveSheetIDsByName 失败 + }} + err := runCreate(t, caller, map[string]string{ + "name": "报表", + "sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]}]`, + }) + if err == nil || !strings.Contains(err.Error(), "NODE_1") || !strings.Contains(err.Error(), "无法回读校验") { + t.Fatalf("取列表失败应带 nodeId 报错: %v", err) + } +} + +// 每个远端步骤失败时,错误必须带上已创建的 nodeId,便于在部分成功后继续操作同一文档。 +func TestSheetCreateFailuresSurfaceNodeIDForRecovery(t *testing.T) { + valuesFlags := map[string]string{"name": "X", "values": `[["a"]]`} + sheetsFlags := map[string]string{"name": "X", "sheets": `[{"name":"S","columns":["a"],"data":[["v"]]}]`} + okSheets := `{"sheets":[{"sheetId":"SHEET_1"}]}` + + cases := []struct { + name string + flags map[string]string + steps []scriptedToolStep + want string + wantNode bool + wantNoErr bool + }{ + { + name: "create-failed", + flags: valuesFlags, + steps: []scriptedToolStep{{err: errors.New("boom")}}, + want: "创建表格失败", + }, + { + name: "create-response-unparseable", + flags: valuesFlags, + steps: []scriptedToolStep{{text: `{`}}, + want: "解析创建结果失败", + }, + { + name: "create-response-without-node-id", + flags: valuesFlags, + steps: []scriptedToolStep{{text: `{"ok":true}`}}, + want: "创建结果未返回 nodeId", + }, + { + name: "probe-never-ready", + flags: valuesFlags, + steps: []scriptedToolStep{{text: `{"nodeId":"NODE_1"}`}, {err: errors.New("not ready")}}, + want: "等待文档就绪失败", + wantNode: true, + }, + { + name: "probe-returns-no-sheets", + flags: valuesFlags, + steps: []scriptedToolStep{{text: `{"nodeId":"NODE_1"}`}, {text: `{"sheets":[]}`}}, + want: "未找到任何工作表", + wantNode: true, + }, + { + name: "write-values-failed", + flags: valuesFlags, + steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {err: errors.New("write failed")}, + }, + want: "但写入数据失败", + wantNode: true, + }, + { + name: "readback-empty-means-not-persisted", + flags: valuesFlags, + steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {text: `{"success":true}`}, + {text: `{"csv":"[row=1] , ,"}`}, // 去掉行号前缀与分隔符后为空 + }, + want: "初始数据写入未生效", + wantNode: true, + }, + { + name: "readback-call-failed", + flags: valuesFlags, + steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {text: `{"success":true}`}, + {err: errors.New("readback boom")}, + }, + want: "回读校验失败", + wantNode: true, + }, + { + name: "rename-default-sheet-failed", + flags: sheetsFlags, + steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {err: errors.New("rename failed")}, + }, + want: "重命名默认工作表失败", + wantNode: true, + }, + { + name: "table-put-failed", + flags: sheetsFlags, + steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {text: `{"success":true}`}, + {err: errors.New("table_put failed")}, + }, + want: "但写入初始数据失败", + wantNode: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{steps: tc.steps} + err := runCreate(t, caller, tc.flags) + if err == nil { + t.Fatalf("err = nil, want contains %q", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if tc.wantNode && !strings.Contains(err.Error(), "NODE_1") { + t.Fatalf("err = %v, want to carry nodeId for recovery", err) + } + }) + } +} + +func TestSheetCreateStyleApplicationFailureReportsIndex(t *testing.T) { + okSheets := `{"sheets":[{"sheetId":"SHEET_1"}]}` + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {text: `{"success":true}`}, // set_range_from_csv + {text: `{"csv":"[row=1]a"}`}, // 回读通过 + {err: errors.New("style rejected")}, // set_cell_range 失败 + }} + err := runCreate(t, caller, map[string]string{ + "name": "X", + "values": `[["a"]]`, + "styles": `{"styles":[{"name":"S","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`, + }) + if err == nil || !strings.Contains(err.Error(), "--styles[0] 应用失败") { + t.Fatalf("err = %v, want --styles[0] 应用失败", err) + } + // 数据已写入成功,错误信息必须同时说明这一点 + if !strings.Contains(err.Error(), "数据已写入") || !strings.Contains(err.Error(), "NODE_1") { + t.Fatalf("err = %v, want to state data was written and carry nodeId", err) + } +} + +func TestSheetCreateAppliesStylesInReviewedOrder(t *testing.T) { + okSheets := `{"sheets":[{"sheetId":"SHEET_1"}]}` + // 建表(1) 探活(1) 定位(1) 写值(1) 回读(1) + 样式 4 次 = 9 + steps := []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, {text: okSheets}, {text: okSheets}, + {text: `{"success":true}`}, {text: `{"csv":"[row=1]a"}`}, + } + for i := 0; i < 4; i++ { + steps = append(steps, scriptedToolStep{text: `{"success":true}`}) + } + caller := &scriptedToolCaller{steps: steps} + if err := runCreate(t, caller, map[string]string{ + "name": "X", + "values": `[["a"]]`, + "styles": `{"styles":[{"name":"S",` + + `"cell_styles":[{"range":"A1","font_weight":"bold"}],` + + `"row_sizes":[{"range":"1:1","type":"pixel","size":28}],` + + `"col_sizes":[{"range":"A:B","type":"standard"}],` + + `"cell_merges":[{"range":"A1:B1","merge_type":"rows"}]}]}`, + }); err != nil { + t.Fatalf("create with full styles: %v", err) + } + if caller.calls != 9 { + t.Fatalf("calls = %d, want 9", caller.calls) + } + // 顺序约定:cell_styles → row_sizes → col_sizes → cell_merges,合并最后 + if caller.tool != "merge_cells" { + t.Fatalf("last tool = %q, want merge_cells", caller.tool) + } + if got, _ := caller.args["mergeType"].(string); got != "mergeRows" { + t.Fatalf("mergeType = %q, want mergeRows (飞书 rows 映射)", got) + } +} + +// ── 纯函数 ─────────────────────────────────────────────────────────────────── + +func TestPlanStyleOpsTranslatesFeishuFieldsAndOrder(t *testing.T) { + ops := sheetStyleOps{ + Name: "S", + CellStyles: []map[string]any{{ + "range": "A1:B2", + "background_color": "#FFF2CC", + "font_color": "#000000", + "font_family": "微软雅黑", + "font_style": "italic", + "font_weight": "bold", + "font_line": "underline", + "horizontal_alignment": "center", + "vertical_alignment": "middle", + "word_wrap": "auto-wrap", + "number_format": "@", + "font_size": float64(12), + "border_styles": map[string]any{"top": map[string]any{"style": "solid"}}, + }}, + RowSizes: []map[string]any{{"range": "1:3", "type": "auto"}}, + ColSizes: []map[string]any{{"range": "A:C", "type": "pixel", "size": float64(120)}}, + CellMerges: []map[string]any{{"range": "A1:B1"}}, + } + calls, err := planStyleOps("N", "S", ops) + if err != nil { + t.Fatalf("planStyleOps: %v", err) + } + wantTools := []string{"set_cell_range", "update_dimension", "update_dimension", "merge_cells"} + if len(calls) != len(wantTools) { + t.Fatalf("calls = %d, want %d", len(calls), len(wantTools)) + } + for i, want := range wantTools { + if calls[i].tool != want { + t.Fatalf("calls[%d].tool = %q, want %q", i, calls[i].tool, want) + } + } + + cells, _ := calls[0].args["cells"].([][]any) + if len(cells) != 2 || len(cells[0]) != 2 { + t.Fatalf("cells shape = %v, want 2x2", cells) + } + cell, _ := cells[0][0].(map[string]any) + cs, _ := cell["cellStyles"].(map[string]any) + // word_wrap 的飞书写法 auto-wrap 必须映射为引擎枚举 autoWrap + if cs["wordWrap"] != "autoWrap" { + t.Fatalf("wordWrap = %v, want autoWrap", cs["wordWrap"]) + } + if cs["fontFamily"] != "微软雅黑" || cs["fontSize"] != 12 { + t.Fatalf("cellStyles = %v", cs) + } + // font_line=underline 展开为两个布尔 + if cs["textUnderline"] != true || cs["textLineThrough"] != false { + t.Fatalf("font-line mapping = %v", cs) + } + if _, ok := cell["borderStyles"]; !ok { + t.Fatal("borderStyles missing") + } + + // ROWS 支持 auto,无需 pixelSize + if calls[1].args["dimension"] != "ROWS" || calls[1].args["sizeType"] != "auto" { + t.Fatalf("row size args = %v", calls[1].args) + } + if _, ok := calls[1].args["pixelSize"]; ok { + t.Fatal("auto 行高不应带 pixelSize") + } + if calls[1].args["startIndex"] != "1" || calls[1].args["length"] != 3 { + t.Fatalf("row range args = %v", calls[1].args) + } + if calls[2].args["dimension"] != "COLUMNS" || calls[2].args["pixelSize"] != 120 { + t.Fatalf("col size args = %v", calls[2].args) + } + if calls[2].args["startIndex"] != "A" || calls[2].args["length"] != 3 { + t.Fatalf("col range args = %v", calls[2].args) + } + // merge_type 缺省时按飞书语义落到 mergeAll + if calls[3].args["mergeType"] != "mergeAll" { + t.Fatalf("mergeType = %v, want mergeAll", calls[3].args["mergeType"]) + } +} + +func TestPlanStyleOpsAcceptsCamelCaseAliases(t *testing.T) { + calls, err := planStyleOps("N", "S", sheetStyleOps{ + CellStyles: []map[string]any{{ + "range": "A1", + "backgroundColor": "#FFF", + "fontSize": 12, + "horizontalAlignment": "left", + "borderStyles": map[string]any{"bottom": map[string]any{"style": "medium"}}, + }}, + }) + if err != nil { + t.Fatalf("camelCase aliases rejected: %v", err) + } + if len(calls) != 1 { + t.Fatalf("calls = %d, want 1", len(calls)) + } +} + +func TestSheetStyleOpsIsEmpty(t *testing.T) { + if !(sheetStyleOps{Name: "S"}).isEmpty() { + t.Fatal("只有 name 应视为空") + } + for _, ops := range []sheetStyleOps{ + {CellStyles: []map[string]any{{}}}, + {RowSizes: []map[string]any{{}}}, + {ColSizes: []map[string]any{{}}}, + {CellMerges: []map[string]any{{}}}, + } { + if ops.isEmpty() { + t.Fatalf("%#v 不应视为空", ops) + } + } +} + +func TestPickStrAndPickNum(t *testing.T) { + m := map[string]any{"a": "", "b": "hit", "n": float64(3), "i": 4, "nil": nil, "bad": true} + if got := pickStr(m, "missing", "a", "b"); got != "hit" { + t.Fatalf("pickStr = %q, want hit(空串应继续找下一个候选键)", got) + } + if got := pickStr(m, "nil", "bad"); got != "" { + t.Fatalf("pickStr = %q, want empty", got) + } + for _, tc := range []struct { + keys []string + want int + ok bool + wantErr string + }{ + {keys: []string{"n"}, want: 3, ok: true}, + {keys: []string{"i"}, want: 4, ok: true}, + {keys: []string{"missing"}}, + // nil 视为未提供,继续找下一个候选键;bool 命中后按类型错误报出, + // 而不是当成"没给"静默放过。 + {keys: []string{"nil", "bad"}, ok: true, wantErr: "必须是数字"}, + } { + got, ok, err := pickNum(m, tc.keys...) + if got != tc.want || ok != tc.ok { + t.Fatalf("pickNum(%v) = (%d,%v), want (%d,%v)", tc.keys, got, ok, tc.want, tc.ok) + } + if tc.wantErr == "" { + if err != nil { + t.Fatalf("pickNum(%v) err = %v, want nil", tc.keys, err) + } + } else if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("pickNum(%v) err = %v, want contains %q", tc.keys, err, tc.wantErr) + } + } +} + +func TestFeishuEnumMappings(t *testing.T) { + for raw, want := range map[string]string{ + "auto-wrap": "autoWrap", "word-clip": "clip", + "overflow": "overflow", "autoWrap": "autoWrap", "": "", + } { + if got := feishuWordWrap(raw); got != want { + t.Fatalf("feishuWordWrap(%q) = %q, want %q", raw, got, want) + } + } + // merge_type 的映射与枚举拒绝由 TestSheetCreateMergeTypeAcceptsEveryLegalForm 覆盖。 + // 原先这里断言 "custom" → "custom",那是把「未知值原样透传」当成期望锁死了, + // 而透传正是导致「文档已建但 merge_cells 被服务端拒绝」的原因。 +} + +func TestParseRowColRange(t *testing.T) { + cases := []struct { + addr string + isRow bool + wantStart string + wantLen int + wantErr bool + }{ + {"1:3", true, "1", 3, false}, + {"3:1", true, "1", 3, false}, // 反序自动纠正 + {"2", true, "2", 1, false}, + {"Sheet1!1:2", true, "1", 2, false}, // 前缀被剥离 + {" 1 : 2 ", true, "1", 2, false}, + {"A:C", false, "A", 3, false}, + // 反序列范围与行分支一致地自动纠正:起始列取较小的那个, + // 否则 "C:A" 会静默改 C/D/E 而不是 A/B/C。 + {"C:A", false, "A", 3, false}, + {"AB:Z", false, "Z", 3, false}, // 多字母列也要按列号而非字典序比较 + {"b", false, "B", 1, false}, + {"", true, "", 0, true}, + {"a:b", true, "", 0, true}, // 行范围收到字母 + {"0:2", true, "", 0, true}, // 行号必须 >= 1 + {"1:0", true, "", 0, true}, + {"1:2", false, "", 0, true}, // 列范围收到数字 + // 带尾随字符的行范围必须整体拒绝:Sscanf("%d") 只消费前缀数字,会把 + // "1x"/"2foo" 静默当成第 1/2 行,放过后 update_dimension 改到错误行。 + {"1x:3", true, "", 0, true}, + {"2foo", true, "", 0, true}, + {"1 2:3", true, "", 0, true}, // 内部空格也非法 + // 列范围拒绝带数字/尾随字符:否则 "A5" 会被 parseA1Cell(补 "1" 后) + // 当成 A 列静默放过。纯多字母列名(如 "AX")是合法的,不在此列。 + {"A5:C", false, "", 0, true}, + {"A1", false, "", 0, true}, + {":C", false, "", 0, true}, // 空列 token 也要拒绝 + {"AX:C", false, "C", 48, false}, // 多字母列合法:C..AX 共 48 列 + } + for _, tc := range cases { + start, length, err := parseRowColRange(tc.addr, tc.isRow) + if tc.wantErr { + if err == nil { + t.Fatalf("parseRowColRange(%q, isRow=%v) 应报错", tc.addr, tc.isRow) + } + continue + } + if err != nil || start != tc.wantStart || length != tc.wantLen { + t.Fatalf("parseRowColRange(%q, isRow=%v) = (%q,%d,%v), want (%q,%d,nil)", + tc.addr, tc.isRow, start, length, err, tc.wantStart, tc.wantLen) + } + } +} + +func TestValuesToCSVAndCellToString(t *testing.T) { + got := valuesToCSV([][]any{ + {"名称", "含,逗号", `含"引号`}, + {nil, true, false}, + {float64(90), float64(1.5), int64(7)}, + }) + want := "名称,\"含,逗号\",\"含\"\"引号\"\n,true,false\n90,1.5,7\n" + if got != want { + t.Fatalf("valuesToCSV =\n%q\nwant\n%q", got, want) + } + if valuesToCSV(nil) != "" { + t.Fatal("空输入应得空串") + } +} + +func TestWriteValuesToSheetSkipsEmptyPayload(t *testing.T) { + installSheetProductArgs(t) + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + if err := writeValuesToSheet(context.Background(), "N", "S", nil); err != nil { + t.Fatalf("writeValuesToSheet(nil): %v", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0(空数据不应发请求)", caller.calls) + } + if err := writeValuesToSheet(context.Background(), "N", "S", [][]any{{"a"}}); err != nil { + t.Fatalf("writeValuesToSheet: %v", err) + } + if caller.tool != "set_range_from_csv" { + t.Fatalf("tool = %q", caller.tool) + } + if caller.args["allowOverwrite"] != true || caller.args["startCell"] != "A1" { + t.Fatalf("args = %v", caller.args) + } +} + +func TestWaitSheetWritableRetriesThenSucceeds(t *testing.T) { + installSheetProductArgs(t) + installImmediateTiming(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {err: errors.New("initializing")}, + {err: errors.New("initializing")}, + {text: `{"sheets":[{"sheetId":"S1"}]}`}, + }} + installScriptedCaller(t, caller) + if err := waitSheetWritable(context.Background(), "N"); err != nil { + t.Fatalf("waitSheetWritable: %v", err) + } + if caller.calls != 3 { + t.Fatalf("calls = %d, want 3(前两次失败后重试成功)", caller.calls) + } +} + +func TestVerifyRangeNotEmpty(t *testing.T) { + installSheetProductArgs(t) + cases := []struct { + name string + step scriptedToolStep + wantErr string + }{ + {"has-content", scriptedToolStep{text: `{"csv":"[row=1]a,b"}`}, ""}, + {"wrapped-result", scriptedToolStep{text: `{"result":{"csv":"[row=1]a"}}`}, ""}, + {"only-separators", scriptedToolStep{text: `{"csv":"[row=1] , ,\n[row=2],"}`}, "数据未落盘"}, + {"empty-csv", scriptedToolStep{text: `{"csv":""}`}, "数据未落盘"}, + {"unparseable", scriptedToolStep{text: `{`}, "无法解析返回"}, + {"call-error", scriptedToolStep{err: errors.New("boom")}, "回读校验失败"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}) + err := verifyRangeNotEmpty(context.Background(), "N", "S", "A1") + if tc.wantErr == "" { + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want contains %q", err, tc.wantErr) + } + }) + } +} + +func TestResolveFirstSheetIDAndParseCreatedNodeID(t *testing.T) { + installSheetProductArgs(t) + for _, tc := range []struct { + name string + step scriptedToolStep + wantID string + wantErr string + }{ + {"ok", scriptedToolStep{text: `{"sheets":[{"sheetId":"S1"},{"sheetId":"S2"}]}`}, "S1", ""}, + {"wrapped", scriptedToolStep{text: `{"result":{"sheets":[{"sheetId":"S9"}]}}`}, "S9", ""}, + {"unparseable", scriptedToolStep{text: `{`}, "", "解析工作表列表失败"}, + {"no-sheets", scriptedToolStep{text: `{"sheets":[]}`}, "", "未找到任何工作表"}, + {"missing-id", scriptedToolStep{text: `{"sheets":[{"name":"x"}]}`}, "", "未返回 sheetId"}, + {"call-error", scriptedToolStep{err: errors.New("boom")}, "", "boom"}, + } { + t.Run("resolveFirstSheetID/"+tc.name, func(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}) + got, err := resolveFirstSheetID(context.Background(), "N") + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want contains %q", err, tc.wantErr) + } + return + } + if err != nil || got != tc.wantID { + t.Fatalf("= (%q,%v), want (%q,nil)", got, err, tc.wantID) + } + }) + } + + for _, tc := range []struct { + text string + want string + wantErr bool + }{ + {`{"nodeId":"N1"}`, "N1", false}, + {`{"result":{"nodeId":"N2"}}`, "N2", false}, + {`{`, "", true}, + {`{"ok":true}`, "", true}, + } { + got, err := parseCreatedNodeID(tc.text) + if tc.wantErr { + if err == nil { + t.Fatalf("parseCreatedNodeID(%q) 应报错", tc.text) + } + continue + } + if err != nil || got != tc.want { + t.Fatalf("parseCreatedNodeID(%q) = (%q,%v)", tc.text, got, err) + } + } +} + +func TestCallMCPToolSilentSuppressesOutput(t *testing.T) { + installSheetProductArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"noisy":true}`}}} + installScriptedCaller(t, caller) + if err := callMCPToolSilent(context.Background(), "update_sheet", map[string]any{"a": 1}); err != nil { + t.Fatalf("callMCPToolSilent: %v", err) + } + if caller.tool != "update_sheet" { + t.Fatalf("tool = %q", caller.tool) + } + installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}) + if err := callMCPToolSilent(context.Background(), "update_sheet", nil); err == nil { + t.Fatal("调用失败应返回错误") + } +} + +func TestParseCreateSheetSpecsAcceptedShapes(t *testing.T) { + for _, raw := range []string{ + `[{"name":"A","columns":["a"]},{"name":"B","columns":["b"]}]`, + `{"sheets":[{"name":"A","columns":["a"]}]}`, + `{"name":"A","columns":["a"]}`, // 单个 spec 对象 + // 契约里的每个可选字段都必须被接受(别把校验写成误拒) + // dtypes/formats 的键按 trim 后的列名查表,因此 " b " 列写 "b" 是对的 + `{"name":"A","columns":["a"," b "],"data":[["x",1],[true,null]],"dtypes":{"a":"object"},` + + `"formats":{"b":"0.00"},"cellStyles":{"a":{"bold":true}},"mode":"append",` + + `"header":false,"allowOverwrite":true,"startCell":"b2"}`, + } { + specs, err := parseCreateSheetSpecs(raw) + if err != nil || len(specs) == 0 { + t.Fatalf("parseCreateSheetSpecs(%s) = (%v,%v)", raw, specs, err) + } + } +} + +func TestApplyStyleOpsPropagatesPlanError(t *testing.T) { + installSheetProductArgs(t) + installScriptedCaller(t, &scriptedToolCaller{}) + err := applyStyleOps(context.Background(), "N", "S", sheetStyleOps{ + CellMerges: []map[string]any{{"merge_type": "all"}}, // 缺 range + }) + if err == nil || !strings.Contains(err.Error(), "缺少必填的 range") { + t.Fatalf("err = %v, want plan error", err) + } +} + +// --styles 的 styles 值不是数组时,内层 Unmarshal 必须报错而不是 panic。 +func TestSheetCreateStylesInnerValueMustBeArray(t *testing.T) { + caller := &scriptedToolCaller{} + err := runCreate(t, caller, map[string]string{ + "name": "X", "values": `[[1]]`, "styles": `{"styles":"notanarray"}`, + }) + if err == nil || !strings.Contains(err.Error(), "--styles 解析失败") { + t.Fatalf("err = %v, want inner unmarshal failure", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0(校验必须早于建文档)", caller.calls) + } +} + +// row_sizes/col_sizes 省略 type 时按 pixel 处理(仍需 size)。 +func TestSheetCreateResizeTypeDefaultsToPixel(t *testing.T) { + caller := &scriptedToolCaller{} + err := runCreate(t, caller, map[string]string{ + "name": "X", "values": `[[1]]`, + "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:1"}]}]}`, + }) + if err == nil || !strings.Contains(err.Error(), "type=pixel 时必须提供正整数 size") { + t.Fatalf("err = %v, want pixel default requiring size", err) + } + + ok := &scriptedToolCaller{steps: append(createOKSteps("A"), scriptedToolStep{text: `{"success":true}`})} + if err := runCreate(t, ok, map[string]string{ + "name": "X", "values": `[[1]]`, + "styles": `{"styles":[{"name":"S","row_sizes":[{"range":"1:1","size":28}]}]}`, + }); err != nil { + t.Fatalf("省略 type + 提供 size 应成功: %v", err) + } +} + +// 定位默认工作表失败(探活已过、随后 get_all_sheets 失败)必须带上 nodeId。 +func TestSheetCreateReportsDefaultSheetLookupFailureWithNodeID(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, // create_workspace_sheet + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, // waitSheetWritable 探活 + {err: errors.New("boom")}, // resolveFirstSheetID 失败 + }} + err := runCreate(t, caller, map[string]string{"name": "X", "values": `[["a"]]`}) + if err == nil || !strings.Contains(err.Error(), "定位默认工作表失败") || + !strings.Contains(err.Error(), "NODE_1") { + t.Fatalf("err = %v, want lookup failure carrying nodeId", err) + } +} + +// --sheets 搭配 --styles:样式按子表 name 定位(而非默认 sheetId)。 +func TestSheetCreateSheetsAppliesStylesBySheetName(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, // create_workspace_sheet + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, // 探活 + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, // resolveFirstSheetID + {text: `{"success":true}`}, // update_sheet 重命名 + {text: `{"success":true}`}, // table_put + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"一月"}]}`}, // resolveSheetIDsByName + {text: `{"csv":"[row=1] 项目\n"}`}, // 一月 回读非空 + {text: `{"success":true}`}, // set_cell_range(样式) + }} + if err := runCreate(t, caller, map[string]string{ + "name": "X", + "sheets": `[{"name":"一月","columns":["项目"],"data":[["房租"]]}]`, + "styles": `{"styles":[{"name":"一月","cell_styles":[{"range":"A1","font_weight":"bold"}]}]}`, + }); err != nil { + t.Fatalf("--sheets + --styles: %v", err) + } + // 样式调用的 sheetId 必须是子表名,不是默认 SHEET_1 + if got := caller.args["sheetId"]; got != "一月" { + t.Fatalf("样式作用于 sheetId=%v, want 一月", got) + } +} + +// merge_type 的全部合法写法(飞书别名 + 引擎原生值 + 省略)都必须放行, +// 且映射到正确的原生枚举。 +func TestSheetCreateMergeTypeAcceptsEveryLegalForm(t *testing.T) { + for input, want := range map[string]string{ + "": "mergeAll", + "all": "mergeAll", + "rows": "mergeRows", + "columns": "mergeColumns", + "mergeAll": "mergeAll", + "mergeRows": "mergeRows", + "mergeColumns": "mergeColumns", + } { + got, err := feishuMergeType(input) + if err != nil || got != want { + t.Errorf("feishuMergeType(%q) = (%q,%v), want (%q,nil)", input, got, err, want) + } + } + for _, bad := range []string{"invalid", "MERGEALL", "merge_all", "none"} { + if _, err := feishuMergeType(bad); err == nil { + t.Errorf("feishuMergeType(%q) 应报错", bad) + } + } +} + +// 回读校验必须瞄准输入里第一个非空单元格。此前固定读 A1,导致 +// [["","姓名"],[1,"张三"]] 这类首格为空的合法数据被误报成"写入未生效", +// 还引导调用方去重复补写。 +func TestSheetCreateVerifiesFirstNonEmptyCellNotAlwaysA1(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, + {text: `{"csv":"[row=1] 姓名\n"}`}, // 回读 B1 命中"姓名" + }} + if err := runCreate(t, caller, map[string]string{ + "name": "X", "values": `[["","姓名"],[1,"张三"]]`, + }); err != nil { + t.Fatalf("首格为空的合法数据不应报写入失败: %v", err) + } + if got := caller.args["range"]; got != "B1" { + t.Fatalf("回读用的 range = %v, want B1(输入里第一个非空格)", got) + } +} + +func TestFirstNonEmptyValuesCell(t *testing.T) { + for _, tc := range []struct { + values [][]any + want string + }{ + {[][]any{{"姓名"}}, "A1"}, + {[][]any{{"", "姓名"}}, "B1"}, + {[][]any{{"", ""}, {"", "张三"}}, "B2"}, + {[][]any{{nil, nil, 0.0}}, "C1"}, + // 第 27 列应是 AA,验证多字母列号 + {[][]any{append(make([]any, 26), "x")}, "AA1"}, + } { + if got := firstNonEmptyValuesCell(tc.values); got != tc.want { + t.Errorf("firstNonEmptyValuesCell(%v) = %q, want %q", tc.values, got, tc.want) + } + } + // 全空时兜底返回 A1(调用方已在更早处拒掉这种输入) + if got := firstNonEmptyValuesCell([][]any{{"", nil}}); got != "A1" { + t.Errorf("全空矩阵兜底 = %q, want A1", got) + } +} + +func TestValuesHaveContent(t *testing.T) { + if valuesHaveContent([][]any{{"", nil}, {""}}) { + t.Error("全空矩阵应判为无内容") + } + for _, v := range [][][]any{ + {{"a"}}, + {{""}, {"", "b"}}, + {{0.0}}, + {{false}}, + } { + if !valuesHaveContent(v) { + t.Errorf("%v 应判为有内容", v) + } + } +} + +func TestPickNumRejectsNonIntegralAndOutOfRange(t *testing.T) { + for _, tc := range []struct { + name string + val any + want string + }{ + {"fractional", 12.9, "必须是整数"}, + {"negative-fractional", -3.5, "必须是整数"}, + {"overflow", 1e20, "超出取值范围"}, + {"underflow", -1e20, "超出取值范围"}, + {"nan", math.NaN(), "不是有限数值"}, + {"inf", math.Inf(1), "不是有限数值"}, + {"string", "12", "必须是数字"}, + {"bool", true, "必须是数字"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok, err := pickNum(map[string]any{"size": tc.val}, "size") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + // 键存在过就要报 ok=true,调用方才知道"给了但非法"而非"没给" + if !ok { + t.Error("ok = false, want true(键存在)") + } + if got != 0 { + t.Errorf("值 = %d, want 0", got) + } + }) + } + + // 合法输入照常返回 + for _, tc := range []struct { + val any + want int + }{ + {float64(12), 12}, + {float64(0), 0}, + {float64(-5), -5}, + {int(28), 28}, + {float64(math.MaxInt32), math.MaxInt32}, + } { + got, ok, err := pickNum(map[string]any{"size": tc.val}, "size") + if err != nil || !ok || got != tc.want { + t.Errorf("pickNum(%v) = (%d,%v,%v), want (%d,true,nil)", tc.val, got, ok, err, tc.want) + } + } + + // 键缺失:ok=false 且无错误 + if got, ok, err := pickNum(map[string]any{}, "size"); got != 0 || ok || err != nil { + t.Errorf("缺键 = (%d,%v,%v), want (0,false,nil)", got, ok, err) + } + // 显式 null 视为未提供 + if _, ok, err := pickNum(map[string]any{"size": nil}, "size"); ok || err != nil { + t.Errorf("null = (ok=%v,err=%v), want (false,nil)", ok, err) + } + // 多候选键:取第一个命中的 + if got, _, _ := pickNum(map[string]any{"fontSize": float64(9)}, "font_size", "fontSize"); got != 9 { + t.Errorf("别名键取值 = %d, want 9", got) + } +} + +// 工作表名唯一性由 parseCreateSheetSpecs 保证;--styles 又强制逐项 name 等于 +// --sheets 对应项,所以 --styles 内部不可能出现重名,无需重复校验。 +func TestParseCreateSheetSpecsRejectsDuplicateNames(t *testing.T) { + for _, tc := range []struct { + name string + sheets string + wantErr string + }{ + { + "two-identical", + `[{"name":"一月","columns":["a"]},{"name":"一月","columns":["a"]}]`, + `--sheets[1].name="一月" 与 --sheets[0] 重复`, + }, + { + // 非相邻重复也要报出首次出现的下标 + "duplicate-not-adjacent", + `[{"name":"a","columns":["c"]},{"name":"b","columns":["c"]},{"name":"a","columns":["c"]}]`, + `--sheets[2].name="a" 与 --sheets[0] 重复`, + }, + { + "wrapped-form-also-checked", + `{"sheets":[{"name":"x","columns":["a"]},{"name":"x","columns":["a"]}]}`, + `--sheets[1].name="x" 与 --sheets[0] 重复`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseCreateSheetSpecs(tc.sheets); err == nil || + !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want contains %q", err, tc.wantErr) + } + }) + } + + // 名称不同照常放行。仅大小写不同不算重复:服务端区分大小写,CLI 不该收紧。 + for _, sheets := range []string{ + `[{"name":"一月","columns":["a"]},{"name":"二月","columns":["a"]}]`, + `[{"name":"Sheet","columns":["a"]},{"name":"sheet","columns":["a"]}]`, + `[{"name":"only","columns":["a"]}]`, + } { + if _, err := parseCreateSheetSpecs(sheets); err != nil { + t.Errorf("parseCreateSheetSpecs(%s) 误拒: %v", sheets, err) + } + } +} + +// 普通 json.Unmarshal 会把 JSON 数字解成 float64,超过 2^53 的整数在写出之前 +// 就被舍入(雪花 ID 1234567890123456789 变成 ...768,20 位订单号更糟),而回读 +// 只校验单元格非空,这种篡改会被报告成写入成功。两条数据通道都必须保留字面量。 +func TestSheetCreatePreservesLargeIntegerLiterals(t *testing.T) { + const snowflake = "1234567890123456789" + const order = "12345678901234567890" + const beyond2p53 = "9007199254740993" + + t.Run("values-to-csv", func(t *testing.T) { + rec, err := runCreateRecording(t, &scriptedToolCaller{steps: createOKSteps(snowflake)}, map[string]string{ + "name": "X", "values": `[["id"],[` + snowflake + `],[` + order + `],[` + beyond2p53 + `]]`, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + // 断言 set_range_from_csv 实际收到的载荷,而不是重算一遍解码 + args := rec.argsFor("set_range_from_csv") + if args == nil { + t.Fatal("未调用 set_range_from_csv") + } + csv, _ := args["csv"].(string) + for _, want := range []string{snowflake, order, beyond2p53} { + if !strings.Contains(csv, want) { + t.Errorf("CSV 未原样保留 %s;实际 = %q", want, csv) + } + } + // 反向守卫:解码若退回 float64,会出现这些被舍入后的值 + for _, bad := range []string{"1234567890123456768", "12345678901234567000", "9007199254740992"} { + if strings.Contains(csv, bad) { + t.Errorf("CSV 含被舍入的值 %s,说明解码仍经过 float64;实际 = %q", bad, csv) + } + } + }) + + t.Run("sheets-to-table-put", func(t *testing.T) { + rec, err := runCreateRecording(t, &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodeId":"NODE_1"}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, + {text: `{"success":true}`}, + {text: `{"success":true}`}, + {text: `{"sheets":[{"sheetId":"SHEET_1","name":"a"}]}`}, // resolveSheetIDsByName + {text: `{"csv":"[row=1] id\n"}`}, // a 回读非空 + }}, map[string]string{ + "name": "X", + "sheets": `[{"name":"a","columns":["id"],"data":[[` + snowflake + `],[` + order + `]]}]`, + }) + if err != nil { + t.Fatalf("create with sheets: %v", err) + } + args := rec.argsFor("table_put") + if args == nil { + t.Fatal("未调用 table_put") + } + raw, marshalErr := json.Marshal(args) + if marshalErr != nil { + t.Fatal(marshalErr) + } + for _, want := range []string{snowflake, order} { + if !strings.Contains(string(raw), want) { + t.Errorf("table_put 未原样保留 %s;实际 = %s", want, raw) + } + } + for _, bad := range []string{"1234567890123456800", "12345678901234567000"} { + if strings.Contains(string(raw), bad) { + t.Errorf("table_put 含被舍入的值 %s;实际 = %s", bad, raw) + } + } + }) +} + +// json.Number 走 cellToString 时必须原样输出,不能再经浮点中转。 +func TestCellToStringKeepsJSONNumberVerbatim(t *testing.T) { + for _, raw := range []string{ + "1234567890123456789", + "12345678901234567890", + "9007199254740993", + "-9007199254740993", + "1.5", + "1e3", + "0", + } { + if got := cellToString(json.Number(raw)); got != raw { + t.Errorf("cellToString(json.Number(%q)) = %q, want 原样", raw, got) + } + } + // float64 分支保持原行为(其他调用方仍可能传 float64) + if got := cellToString(float64(12)); got != "12" { + t.Errorf("float64(12) = %q, want 12", got) + } + if got := cellToString(1.5); got != "1.5" { + t.Errorf("float64(1.5) = %q, want 1.5", got) + } +} + +// callRecorder 包装 scriptedToolCaller 并记下每一次调用。共享的 +// scriptedToolCaller 只保留最后一次的 tool/args,无法断言多步编排里中间某一步 +// 实际发出的载荷(写入是第 4 次调用、回读是第 5 次)。InitDeps 接受 +// edition.ToolCaller 接口,所以这个包装只存在于本文件,不必改动共享 helper: +// 嵌入 *scriptedToolCaller 继承其余接口方法,只覆写 CallTool。 +type callRecorder struct { + *scriptedToolCaller + calls []recordedCall +} + +type recordedCall struct { + tool string + args map[string]any +} + +func (r *callRecorder) CallTool(ctx context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { + r.calls = append(r.calls, recordedCall{tool: toolName, args: args}) + return r.scriptedToolCaller.CallTool(ctx, serverID, toolName, args) +} + +// argsFor 返回指定工具第一次被调用时的参数;未调用过则返回 nil。 +func (r *callRecorder) argsFor(tool string) map[string]any { + for _, c := range r.calls { + if c.tool == tool { + return c.args + } + } + return nil +} + +// runCreateRecording 与 runCreate 等价,但额外记录全部调用以便断言中间步骤。 +func runCreateRecording(t *testing.T, inner *scriptedToolCaller, flags map[string]string) (*callRecorder, error) { + t.Helper() + rec := &callRecorder{scriptedToolCaller: inner} + testseam.Protect(t, &deps) + InitDeps(rec) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + installImmediateTiming(t) + installSheetProductArgs(t) + cmd := newSheetCreateWithDataCmd() + for name, value := range flags { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return rec, cmd.RunE(cmd, nil) +} diff --git a/internal/helpers/sheet_data.go b/internal/helpers/sheet_data.go index 0ea729d5..3cdb77b0 100644 --- a/internal/helpers/sheet_data.go +++ b/internal/helpers/sheet_data.go @@ -129,6 +129,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 --match-case 区分大小写(默认 false) --match-entire-cell 完整单元格匹配(默认 false) --use-regexp 使用正则表达式匹配(默认 false) + --match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值) --include-hidden 包含隐藏行/列(默认 false) --range 限定替换范围,A1 表示法(不传时在整个工作表中替换) @@ -166,6 +167,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 toolArgs["matchEntireCell"] = matchEntireCell useRegExp, _ := cmd.Flags().GetBool("use-regexp") toolArgs["useRegExp"] = useRegExp + matchFormula, _ := cmd.Flags().GetBool("match-formula") + toolArgs["matchFormulaText"] = matchFormula includeHidden, _ := cmd.Flags().GetBool("include-hidden") toolArgs["includeHidden"] = includeHidden return callMCPTool("replace_all", toolArgs) @@ -198,6 +201,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 }, Parameters: []contract.ParamDecl{ {Name: "find", Property: "text"}, + {Name: "match-formula", Property: "matchFormulaText"}, {Name: "node", Property: "nodeId"}, {Name: "replacement", Property: "replaceText"}, {Name: "use-regexp", Property: "useRegExp"}, @@ -212,6 +216,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 replaceCmd.Flags().Bool("match-case", false, "区分大小写 (默认 false)") replaceCmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配") replaceCmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配") + replaceCmd.Flags().Bool("match-formula", false, "在公式文本中查找替换(默认 false)") replaceCmd.Flags().Bool("include-hidden", false, "包含隐藏行/列") appendCmd := &cobra.Command{ diff --git a/internal/helpers/sheet_dimension.go b/internal/helpers/sheet_dimension.go index 3ae07ec8..4ce8d7ba 100644 --- a/internal/helpers/sheet_dimension.go +++ b/internal/helpers/sheet_dimension.go @@ -3,12 +3,21 @@ package helpers import ( "encoding/json" "fmt" + "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) +// sizeTypeEnumHint 返回该维度支持的尺寸模式列表(行高含 auto,列宽不含,与飞书一致)。 +func sizeTypeEnumHint(dimension string) string { + if dimension == "ROWS" { + return "pixel / standard / auto" + } + return "pixel / standard" +} + // newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension, // merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown). func newDimensionCmds() []*cobra.Command { @@ -43,8 +52,11 @@ func newDimensionCmds() []*cobra.Command { return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension) } lengthStr := mustGetFlag(cmd, "length") - var length int - if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 { + // strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀 + // 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/ + // 调整——删除方向不可回滚。 + length, lengthErr := strconv.Atoi(lengthStr) + if lengthErr != nil || length < 1 { return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr) } if length > 5000 { @@ -428,8 +440,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension) } lengthStr := mustGetFlag(cmd, "length") - var length int - if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 { + // strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀 + // 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/ + // 调整——删除方向不可回滚。 + length, lengthErr := strconv.Atoi(lengthStr) + if lengthErr != nil || length < 1 { return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr) } if length > 5000 { @@ -490,9 +505,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 --dimension COLUMNS 时,--start-index 为列字母,如 "A" 表示从 A 列开始、"AB" 表示从 AB 列开始 支持在 --start-index 中携带工作表前缀(如 "Sheet1!3" / "Sheet1!A"),此时将忽略 --sheet-id。 ---hidden 与 --pixel-size 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 +--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)。 +当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 --pixel-size 单位为像素,dimension=ROWS 时表示行高、dimension=COLUMNS 时表示列宽。 +尺寸模式(--size-type,对齐飞书): + pixel 默认,按 --pixel-size 指定的像素值设置 + standard 恢复默认行高/列宽,无需 --pixel-size + auto 按内容自适应行高,仅 ROWS 支持(列宽不提供自适应),无需 --pixel-size + 常见场景:隐藏/显示指定连续行或列、批量调整行高/列宽、在同一次调用中同时修改尺寸与显隐。`, Example: ` # 隐藏第 3~4 行 dws sheet update-dimension --node NODE_ID --sheet-id SHEET_ID --dimension ROWS --start-index "3" --length 2 --hidden @@ -517,8 +538,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension) } lengthStr := mustGetFlag(cmd, "length") - var length int - if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 { + // strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀 + // 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/ + // 调整——删除方向不可回滚。 + length, lengthErr := strconv.Atoi(lengthStr) + if lengthErr != nil || length < 1 { return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr) } if length > 5000 { @@ -526,8 +550,30 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 } hiddenChanged := cmd.Flags().Changed("hidden") pixelSizeChanged := cmd.Flags().Changed("pixel-size") - if !hiddenChanged && !pixelSizeChanged { - return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个") + sizeType, _ := cmd.Flags().GetString("size-type") + sizeType = strings.ToLower(strings.TrimSpace(sizeType)) + // 尺寸模式枚举按维度区分(与飞书一致):行高有 auto,列宽只有 pixel / standard + switch { + case sizeType == "" || sizeType == "pixel" || sizeType == "standard": + case sizeType == "auto" && dimension == "ROWS": + case sizeType == "auto": + return fmt.Errorf("--size-type 对 COLUMNS 仅支持 pixel / standard(列宽不提供自适应)") + default: + return fmt.Errorf("--size-type 必须为 %s,当前值: %s", sizeTypeEnumHint(dimension), sizeType) + } + // standard/auto 由服务端决定尺寸,无需 --pixel-size + sizeTypeDrivesSize := sizeType == "standard" || sizeType == "auto" + if !hiddenChanged && !pixelSizeChanged && !sizeTypeDrivesSize { + return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)") + } + // standard/auto 说"尺寸交给服务端",--pixel-size 又指定固定像素, + // 两者语义直接冲突;同时下发会得到依赖服务端实现的结果,故先拒。 + if sizeTypeDrivesSize && pixelSizeChanged { + return fmt.Errorf("--size-type %s 表示尺寸由服务端决定,不能同时指定 --pixel-size;要指定固定像素请用 --size-type pixel(或省略 --size-type)", sizeType) + } + // 反向:显式声明 pixel 模式却不给像素值,同样是不完整的请求。 + if sizeType == "pixel" && !pixelSizeChanged { + return fmt.Errorf("--size-type pixel 必须配合 --pixel-size 指定像素值;若本次只改显隐,请省略 --size-type") } toolArgs := map[string]any{ "nodeId": mustGetFlag(cmd, "node"), @@ -547,6 +593,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 } toolArgs["pixelSize"] = pixelSize } + if sizeType != "" { + toolArgs["sizeType"] = sizeType + } return callMCPTool("update_dimension", toolArgs) }, } @@ -587,6 +636,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 updateDimensionCmd.Flags().String("length", "", "更新数量,正整数 (必填),最大 5000") updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)") updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽") + updateDimensionCmd.Flags().String("size-type", "", "尺寸模式(对齐飞书): pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)") groupDimensionCmd := &cobra.Command{ Use: "group-dimension", diff --git a/internal/helpers/sheet_dimension_coverage_more_test.go b/internal/helpers/sheet_dimension_coverage_more_test.go index f6a409cd..ac366b28 100644 --- a/internal/helpers/sheet_dimension_coverage_more_test.go +++ b/internal/helpers/sheet_dimension_coverage_more_test.go @@ -2,6 +2,7 @@ package helpers import ( "io" + "strings" "testing" "github.com/spf13/cobra" @@ -74,6 +75,84 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T) } } +// --length 必须整个值都是合法正整数。改用 strconv.Atoi 之前走的是 +// fmt.Sscanf("%d"),只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作 +// ——delete 方向不可回滚。这是对既有命令的用户可见行为变更(已记入 CHANGELOG), +// 用测试钉住,避免哪天改回宽松解析没人发现。 +func TestDimensionLengthRejectsTrailingCharacters(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + common := []string{"--node", "node", "--sheet-id", "sheet", "--dimension", "ROWS"} + + for _, cmdName := range []string{"insert-dimension", "delete-dimension", "update-dimension"} { + locator := []string{"--position", "1"} + if cmdName == "update-dimension" { + locator = []string{"--start-index", "1", "--hidden"} + } + for _, bad := range []string{"2x", "3foo", "1 2", "0x10", "abc", ""} { + args := append(append(append([]string{}, common...), locator...), "--length", bad) + err := executeDimensionCoverage(t, cmdName, args...) + if err == nil { + t.Errorf("%s --length %q 未报错,畸形长度被静默接受", cmdName, bad) + continue + } + if !strings.Contains(err.Error(), "--length") { + t.Errorf("%s --length %q: err = %v, want 指明 --length", cmdName, bad, err) + } + } + } +} + +// --size-type 的枚举按维度区分:行高有 auto,列宽只有 pixel / standard(对齐飞书)。 +func TestUpdateDimensionSizeTypeEnumIsDimensionScoped(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{dry: true}) + common := []string{"--node", "node", "--sheet-id", "sheet", "--start-index", "1", "--length", "2"} + + for _, tc := range []struct { + name string + dimension string + sizeType string + wantErr string + }{ + // standard / auto 由服务端决定尺寸,不再要求 --hidden 或 --pixel-size + {"rows-standard", "ROWS", "standard", ""}, + {"rows-auto", "ROWS", "auto", ""}, + {"columns-standard", "COLUMNS", "standard", ""}, + {"columns-auto-rejected", "COLUMNS", "auto", "仅支持 pixel / standard"}, + // 非法枚举的提示文案必须按维度给出不同的合法值列表 + {"rows-bogus-hint", "ROWS", "bogus", "必须为 pixel / standard / auto"}, + {"columns-bogus-hint", "COLUMNS", "bogus", "必须为 pixel / standard"}, + } { + t.Run(tc.name, func(t *testing.T) { + args := append(append([]string{}, common...), "--dimension", tc.dimension, "--size-type", tc.sizeType) + err := executeDimensionCoverage(t, "update-dimension", args...) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want contains %q", err, tc.wantErr) + } + }) + } + + // pixel 仍需 --hidden 或 --pixel-size 之一 + args := append(append([]string{}, common...), "--dimension", "ROWS", "--size-type", "pixel") + if err := executeDimensionCoverage(t, "update-dimension", args...); err == nil { + t.Fatal("size-type=pixel 且未给 --pixel-size/--hidden 时应报错") + } +} + +func TestSizeTypeEnumHint(t *testing.T) { + if got := sizeTypeEnumHint("ROWS"); got != "pixel / standard / auto" { + t.Fatalf("ROWS hint = %q", got) + } + if got := sizeTypeEnumHint("COLUMNS"); got != "pixel / standard" { + t.Fatalf("COLUMNS hint = %q", got) + } +} + func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) { installScriptedCaller(t, &scriptedToolCaller{dry: true}) base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"} @@ -86,3 +165,80 @@ func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) t.Fatalf("multi-select dropdown: %v", err) } } + +// --size-type standard/auto 与 --pixel-size 语义冲突(一个说交给服务端、一个 +// 指定固定像素),必须在发请求前拒;反向 pixel 模式也必须带像素值。 +func TestUpdateDimensionRejectsSizeTypeAndPixelSizeConflict(t *testing.T) { + for _, tc := range []struct { + name string + flags [][2]string + want string + }{ + { + "standard-with-pixel-size", + [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}, {"pixel-size", "40"}}, + "不能同时指定 --pixel-size", + }, + { + "auto-with-pixel-size", + [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}, {"pixel-size", "40"}}, + "不能同时指定 --pixel-size", + }, + { + "explicit-pixel-without-size", + [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"hidden", "true"}}, + "--size-type pixel 必须配合 --pixel-size", + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...) + err := runUpdateDimensionForTest(t, caller, flags) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0(冲突必须在发请求前拒)", caller.calls) + } + }) + } + + // 合法组合仍然放行 + for _, tc := range []struct { + name string + flags [][2]string + }{ + {"standard-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}}}, + {"auto-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}}}, + {"pixel-with-size", [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"pixel-size", "40"}}}, + {"pixel-size-without-size-type", [][2]string{{"dimension", "COLUMNS"}, {"pixel-size", "120"}}}, + {"hidden-only", [][2]string{{"dimension", "ROWS"}, {"hidden", "true"}}}, + } { + t.Run("ok/"+tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}} + flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...) + if err := runUpdateDimensionForTest(t, caller, flags); err != nil { + t.Fatalf("合法组合被拒: %v", err) + } + }) + } +} + +func runUpdateDimensionForTest(t *testing.T, caller *scriptedToolCaller, flags [][2]string) error { + t.Helper() + installScriptedCaller(t, caller) + installSheetProductArgs(t) + for _, c := range newDimensionCmds() { + if c.Name() != "update-dimension" { + continue + } + for _, kv := range flags { + if err := c.Flags().Set(kv[0], kv[1]); err != nil { + t.Fatalf("set --%s: %v", kv[0], err) + } + } + return c.RunE(c, nil) + } + t.Fatal("update-dimension not found") + return nil +} diff --git a/internal/helpers/sheet_export_csv.go b/internal/helpers/sheet_export_csv.go new file mode 100644 index 00000000..3e56d8db --- /dev/null +++ b/internal/helpers/sheet_export_csv.go @@ -0,0 +1,241 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/spf13/cobra" +) + +// ============================================================================ +// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV +// +// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子 +// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。 +// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一 +// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误 +// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。 +// +// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按 +// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。 +// ============================================================================ + +func newSheetExportCsvCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "export-csv", + Short: "导出单个工作表为纯 CSV(同步)", + Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。 + +整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。 + +参数说明: + --node 表格文档 ID 或链接 URL,系统自动识别(必填) + --sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表) + --range 导出范围,A1 表示法(不传则整表;大表可用此分块导出) + --value-render-option 取值模式: formatted_value(默认) / raw_value / formula + --output 本地保存路径(可选)。可为文件路径或目录(目录时存为 + sheet-export.csv);不传则把 CSV 打到 stdout + --allow-truncated 允许数据被截断时仍然导出 + +截断行为: + 表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、 + 或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。 + +落盘保证: + 写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误) + 不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。 + +权限要求: + 当前用户对目标表格具备可查看权限。`, + Example: ` # 导出第一个工作表到 stdout(可管道处理) + dws sheet export-csv --node NODE_ID + + # 导出指定工作表为本地 CSV 文件 + dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv + + # 只导出某个范围,取原始值 + dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`, + RunE: runSheetExportCsv, + } + DeclareLeafMetadata(cmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "sheet", + Name: "export_csv", + CanonicalPath: "sheet.export_csv", + CLIPath: "sheet export-csv", + PrimaryCLIPath: "sheet export-csv", + }, + Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。", + DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。", + UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"}, + AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"}, + Examples: []string{ + "dws sheet export-csv --node --sheet-id --output ./data.csv", + "dws sheet export-csv --node --range A1:Z1000", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "nodeId"}, + {Name: "range", Property: "range"}, + {Name: "sheet-id", Property: "sheetId"}, + {Name: "value-render-option", Property: "valueRenderOption"}, + }, + }, + }) + cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") + cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)") + cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)") + cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula") + cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout") + cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出") + return cmd +} + +// valueRenderOptionEnum 是 --value-render-option 的合法取值。 +var valueRenderOptionEnum = map[string]bool{ + "formatted_value": true, "raw_value": true, "formula": true, +} + +// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。 +func runSheetExportCsv(cmd *cobra.Command, _ []string) error { + nodeID := mustGetFlag(cmd, "node") + if nodeID == "" { + return fmt.Errorf("flag --node is required") + } + sheetID, _ := cmd.Flags().GetString("sheet-id") + rangeAddr, _ := cmd.Flags().GetString("range") + valueRenderOption, _ := cmd.Flags().GetString("value-render-option") + valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption)) + if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] { + return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption) + } + outputPath, _ := cmd.Flags().GetString("output") + allowTruncated, _ := cmd.Flags().GetBool("allow-truncated") + + if deps.Caller.DryRun() { + deps.Out.PrintKeyValue("操作", "导出工作表为 CSV") + deps.Out.PrintKeyValue("节点", nodeID) + if sheetID != "" { + deps.Out.PrintKeyValue("工作表", sheetID) + } + if outputPath != "" { + deps.Out.PrintKeyValue("输出", outputPath) + } + return nil + } + + ctx := context.Background() + toolArgs := map[string]any{ + "nodeId": nodeID, + "annotateRowNumbers": false, + } + if sheetID != "" { + toolArgs["sheetId"] = sheetID + } + if rangeAddr != "" { + toolArgs["range"] = rangeAddr + } + if valueRenderOption != "" { + toolArgs["valueRenderOption"] = valueRenderOption + } + + // CSV 正文走 stdout,进度/警告一律不能污染它。 + text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs) + if err != nil { + return fmt.Errorf("读取 CSV 失败: %w", err) + } + + csvContent, hasMore, err := parseGetRangeAsCsvResult(text) + if err != nil { + return err + } + + // 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0, + // 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标 + // 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果 + // 必须显式加 --allow-truncated。 + if hasMore && !allowTruncated { + return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+ + "请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+ + "或确认可接受不完整数据后加 --allow-truncated", + firstNonEmpty(outputPath, "stdout")) + } + if hasMore { + deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" + + "请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。") + } + + if outputPath == "" { + deps.Out.PrintRaw(csvContent) + return nil + } + if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() { + outputPath = filepath.Join(outputPath, "sheet-export.csv") + } + // 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、 + // 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename, + // 失败时原文件保持不变。 + // AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录 + // 不存在即报错」语义,避免把拼错的路径悄悄建成目录。 + if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil { + return fmt.Errorf("写入 CSV 文件失败: %w", statErr) + } + if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil { + return fmt.Errorf("写入 CSV 文件失败: %w", err) + } + if hasMore { + deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath)) + return nil + } + deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath)) + return nil +} + +// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。 +// +// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进 +// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。 +// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。 +func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) { + var data map[string]any + if e := json.Unmarshal([]byte(text), &data); e != nil { + return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e) + } + if raw, wrapped := data["result"]; wrapped { + result, ok := raw.(map[string]any) + if !ok { + return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text) + } + data = result + } + raw, exists := data["csv"] + if !exists { + return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text) + } + csvVal, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text) + } + csv = csvVal + if hm, ok := data["hasMore"].(bool); ok { + hasMore = hm + } + return csv, hasMore, nil +} diff --git a/internal/helpers/sheet_export_csv_test.go b/internal/helpers/sheet_export_csv_test.go new file mode 100644 index 00000000..25bddc89 --- /dev/null +++ b/internal/helpers/sheet_export_csv_test.go @@ -0,0 +1,353 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package helpers + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/spf13/pflag" +) + +func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + installScriptedCaller(t, caller) + oldArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = oldArgs }) + cmd := newSheetExportCsvCmd() + for index := 0; index < len(args); index += 2 { + if err := cmd.Flags().Set(args[index], args[index+1]); err != nil { + t.Fatalf("set %s: %v", args[index], err) + } + } + return runSheetExportCsv(cmd, nil) +} + +// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里 +// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报 +// 成功。这里钉住两条命令的 flag 面互不渗透。 +func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) { + xlsxOnly := []string{} + newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) }) + sort.Strings(xlsxOnly) + if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) { + t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want) + } + + csvFlags := map[string]bool{} + newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true }) + for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} { + if !csvFlags[name] { + t.Errorf("sheet export-csv 缺少 flag --%s", name) + } + } + // 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。 + if csvFlags["export-format"] { + t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)") + } +} + +func TestSheetExportCsvRequiresNode(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeSheetExportCsv(t, caller) + if err == nil || !strings.Contains(err.Error(), "--node is required") { + t.Fatalf("err = %v, want required node", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0", caller.calls) + } +} + +// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。 +func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty") + if err == nil || + !strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") { + t.Fatalf("err = %v, want enum rejection", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls) + } + + for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} { + ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} + if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil { + t.Fatalf("value-render-option=%q 应被接受: %v", option, err) + } + } +} + +func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) { + caller := &scriptedToolCaller{dry: true} + if err := executeSheetExportCsv(t, caller, + "node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil { + t.Fatalf("csv dry run: %v", err) + } + if caller.calls != 0 { + t.Fatalf("dry run 不应发请求, calls = %d", caller.calls) + } +} + +func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"csv":"a,b\n","hasMore":true}`}, + }} + // 截断默认失败,所以要显式放行才能走到落盘/输出。 + if err := executeSheetExportCsv(t, caller, + "node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1", + "value-render-option", "raw_value", "allow-truncated", "true"); err != nil { + t.Fatalf("csv export: %v", err) + } + for _, kv := range [][2]string{ + {"range", "A1:Z1000"}, + {"sheetId", "SHEET_1"}, + {"valueRenderOption", "raw_value"}, + } { + if got := caller.args[kv[0]]; got != kv[1] { + t.Errorf("%s 未透传: %#v", kv[0], got) + } + } + // csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。 + if caller.args["annotateRowNumbers"] != false { + t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"]) + } +} + +// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把 +// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。 +func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out.csv") + if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil { + t.Fatal(err) + } + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"csv":"a,b\n","hasMore":true}`}, + }} + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out) + if err == nil { + t.Fatal("截断时未放行应报错,而非静默成功") + } + for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("错误信息缺少 %q: %v", want, err) + } + } + // 关键:既有文件必须保持原样,不能被截断数据覆盖。 + body, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatalf("read output: %v", readErr) + } + if string(body) != "PREEXISTING\n" { + t.Fatalf("目标文件被截断数据覆盖了: %q", string(body)) + } +} + +// 显式放行后允许落盘,但成功信息必须点明数据不完整。 +func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out.csv") + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"csv":"a,b\n","hasMore":true}`}, + }} + if err := executeSheetExportCsv(t, caller, + "node", "NODE", "output", out, "allow-truncated", "true"); err != nil { + t.Fatalf("allow-truncated 应允许导出: %v", err) + } + body, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read output: %v", err) + } + if string(body) != "a,b\n" { + t.Fatalf("csv 内容 = %q", string(body)) + } +} + +// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。 +func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "out.csv") + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}} + if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil { + t.Fatalf("完整读取不应要求放行: %v", err) + } + body, err := os.ReadFile(out) + if err != nil || string(body) != "a,b\n" { + t.Fatalf("csv 内容 = %q, err = %v", string(body), err) + } +} + +func TestSheetExportCsvWritesIntoDirectory(t *testing.T) { + dir := t.TempDir() + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} + if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil { + t.Fatalf("csv export: %v", err) + } + body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv")) + if err != nil { + t.Fatalf("read exported csv: %v", err) + } + if string(body) != "a,b\n" { + t.Fatalf("csv 内容 = %q", string(body)) + } +} + +// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、 +// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。 +func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) { + out := filepath.Join(t.TempDir(), "existing.csv") + const original = "原有重要数据\n1,2,3\n" + if err := os.WriteFile(out, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + // 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。 + previousRename := atomicRename + t.Cleanup(func() { atomicRename = previousRename }) + atomicRename = func(string, string) error { return errors.New("no space left on device") } + + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out) + if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") { + t.Fatalf("err = %v, want write failure", err) + } + + body, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatalf("read output: %v", readErr) + } + if string(body) != original { + t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body)) + } + + // 临时文件不能留在目录里冒充导出产物。 + entries, readDirErr := os.ReadDir(filepath.Dir(out)) + if readDirErr != nil { + t.Fatalf("read dir: %v", readDirErr) + } + if len(entries) != 1 || entries[0].Name() != filepath.Base(out) { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Fatalf("目录残留临时文件: %v", names) + } +} + +func TestSheetExportCsvReportsWriteFailure(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} + missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv") + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing) + if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") { + t.Fatalf("err = %v, want write failure", err) + } +} + +func TestSheetExportCsvReportsReadFailure(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}} + err := executeSheetExportCsv(t, caller, "node", "NODE") + if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") { + t.Fatalf("err = %v, want read failure", err) + } +} + +func TestParseGetRangeAsCsvResult(t *testing.T) { + // 兼容裸响应与 result 包装两种形状 + for _, body := range []string{ + `{"csv":"a,b\n","hasMore":true}`, + `{"result":{"csv":"a,b\n","hasMore":true}}`, + } { + csv, hasMore, err := parseGetRangeAsCsvResult(body) + if err != nil || csv != "a,b\n" || !hasMore { + t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err) + } + } + // 字段存在但为空串是合法的(真的空区域) + csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`) + if err != nil || csv != "" || hasMore { + t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err) + } +} + +// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用 +// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。 +func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) { + for _, tc := range []struct{ body, want string }{ + {"not json", "解析 get_range_as_csv 响应失败"}, + {`{"message":"something odd"}`, "缺少 csv 字段"}, + {`{"csv":123}`, "csv 字段不是字符串"}, + {`{"csv":null}`, "csv 字段不是字符串"}, + {`{"result":"not-an-object"}`, "result 不是对象"}, + {`{"result":{"message":"odd"}}`, "缺少 csv 字段"}, + } { + if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil || + !strings.Contains(err.Error(), tc.want) { + t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want) + } + } +} + +// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。 +func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) { + out := filepath.Join(t.TempDir(), "existing.csv") + const original = "原有重要数据\n1,2,3\n" + if err := os.WriteFile(out, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}} + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out) + if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") { + t.Fatalf("err = %v, want missing csv field", err) + } + body, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatal(readErr) + } + if string(body) != original { + t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body)) + } +} + +// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会 +// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。 +func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}} + installScriptedCaller(t, caller) + installSheetProductArgs(t) + var stdout, stderr bytes.Buffer + deps.Out.w, deps.Out.errW = &stdout, &stderr + + cmd := newSheetExportCsvCmd() + // 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。 + for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} { + if err := cmd.Flags().Set(kv[0], kv[1]); err != nil { + t.Fatalf("set --%s: %v", kv[0], err) + } + } + if err := runSheetExportCsv(cmd, nil); err != nil { + t.Fatalf("csv export: %v", err) + } + + if got := stdout.String(); got != "a,b\nc,d\n" { + t.Fatalf("stdout = %q, want 纯 CSV 正文", got) + } + if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") { + t.Fatalf("stdout 混入了警告文本: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "CSV 已被截断") { + t.Fatalf("stderr = %q, want 截断提示", stderr.String()) + } +} diff --git a/internal/helpers/sheet_style.go b/internal/helpers/sheet_style.go index ce4fbeff..3e3cb87a 100644 --- a/internal/helpers/sheet_style.go +++ b/internal/helpers/sheet_style.go @@ -1,18 +1,22 @@ package helpers import ( - "context" "encoding/json" "fmt" "os" "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) // ============================================================================ -// set-style / batch-set-style 辅助函数与命令(透传 update_range 样式字段) +// set-style / batch-set-style +// +// 方案 A(对齐飞书 +cells-set-style):样式统一走 set_cell_range 的 cellStyles 路径, +// 每个单元格产出 {"cellStyles":{...}}(不写值,保留原值);纯样式写入对合并单元格安全 +// (底层 setComplexValues 仅对写值 cell 拦截合并冲突)。 // ============================================================================ // parseA1Range 解析 A1 表示法(支持 "Sheet1!A1:B3" / "A1:B3" / "A1"),返回 rows/cols。 @@ -69,171 +73,6 @@ func parseA1Cell(s string) (col, row int, err error) { return col, row, nil } -func fillStringMatrix(rows, cols int, v string) [][]string { - m := make([][]string, rows) - for i := range m { - row := make([]string, cols) - for j := range row { - row[j] = v - } - m[i] = row - } - return m -} - -func fillIntMatrix(rows, cols int, v int) [][]int { - m := make([][]int, rows) - for i := range m { - row := make([]int, cols) - for j := range row { - row[j] = v - } - m[i] = row - } - return m -} - -var ( - hAlignEnum = map[string]bool{"left": true, "center": true, "right": true, "general": true} - vAlignEnum = map[string]bool{"top": true, "middle": true, "bottom": true} - fontWeightEnum = map[string]bool{"bold": true, "normal": true} - wordWrapEnum = map[string]bool{"overflow": true, "clip": true, "autoWrap": true} -) - -// styleSpec 描述一次 set-style 调用的样式参数(与 CLI flag / 批次 JSON 对齐)。 -type styleSpec struct { - BgColor string `json:"bgColor,omitempty"` - BgColorsJSON string `json:"bgColorsJson,omitempty"` - FontSize int `json:"fontSize,omitempty"` - FontSizesJSON string `json:"fontSizesJson,omitempty"` - HAlign string `json:"hAlign,omitempty"` - HAlignsJSON string `json:"hAlignsJson,omitempty"` - VAlign string `json:"vAlign,omitempty"` - VAlignsJSON string `json:"vAlignsJson,omitempty"` - FontColor string `json:"fontColor,omitempty"` - FontColorsJSON string `json:"fontColorsJson,omitempty"` - FontWeight string `json:"fontWeight,omitempty"` - FontWeightsJSON string `json:"fontWeightsJson,omitempty"` - WordWrap string `json:"wordWrap,omitempty"` - NumberFormat string `json:"numberFormat,omitempty"` -} - -// batchItem 是 batch-set-style 批次配置里的一项 -// 嵌入 styleSpec,额外必填 sheetId + range。 -type batchItem struct { - SheetID string `json:"sheetId"` - Range string `json:"range"` - styleSpec -} - -// applyStyleSpec 将 styleSpec 铺到 toolArgs,同时做枚举/维度/上限校验。 -func applyStyleSpec(spec *styleSpec, rows, cols int, toolArgs map[string]any) error { - if rows <= 0 || cols <= 0 { - return fmt.Errorf("range 行列数必须大于 0") - } - if rows > 1000 { - return fmt.Errorf("单次样式更新行数上限为 1000(当前 %d 行)", rows) - } - if rows*cols > 30000 { - return fmt.Errorf("单次样式更新单元格总数上限为 30000(当前 %d×%d=%d)", rows, cols, rows*cols) - } - if err := apply2DString(spec.BgColor, spec.BgColorsJSON, rows, cols, "bg-color", "backgroundColors", nil, toolArgs); err != nil { - return err - } - if err := applyFontSize(spec, rows, cols, toolArgs); err != nil { - return err - } - if err := apply2DString(spec.HAlign, spec.HAlignsJSON, rows, cols, "h-align", "horizontalAlignments", hAlignEnum, toolArgs); err != nil { - return err - } - if err := apply2DString(spec.VAlign, spec.VAlignsJSON, rows, cols, "v-align", "verticalAlignments", vAlignEnum, toolArgs); err != nil { - return err - } - if err := apply2DString(spec.FontColor, spec.FontColorsJSON, rows, cols, "font-color", "fontColors", nil, toolArgs); err != nil { - return err - } - if err := apply2DString(spec.FontWeight, spec.FontWeightsJSON, rows, cols, "font-weight", "fontWeights", fontWeightEnum, toolArgs); err != nil { - return err - } - if spec.WordWrap != "" { - if !wordWrapEnum[spec.WordWrap] { - return fmt.Errorf("--word-wrap 枚举非法: %s(合法值: overflow / clip / autoWrap)", spec.WordWrap) - } - toolArgs["wordWrap"] = spec.WordWrap - } - if spec.NumberFormat != "" { - toolArgs["numberFormat"] = spec.NumberFormat - } - for _, k := range []string{"backgroundColors", "fontSizes", "horizontalAlignments", "verticalAlignments", "fontColors", "fontWeights", "wordWrap", "numberFormat"} { - if _, ok := toolArgs[k]; ok { - return nil - } - } - return fmt.Errorf("至少需要指定一个样式参数(--bg-color / --font-size / --h-align / --v-align / --font-color / --font-weight / --word-wrap / --number-format 或对应的 *-json 形式)") -} - -func applyFontSize(spec *styleSpec, rows, cols int, toolArgs map[string]any) error { - if spec.FontSize != 0 && spec.FontSizesJSON != "" { - return fmt.Errorf("--font-size 与 --font-sizes-json 不能同时指定") - } - if spec.FontSize != 0 { - if spec.FontSize < 0 { - return fmt.Errorf("--font-size 必须为正整数") - } - toolArgs["fontSizes"] = fillIntMatrix(rows, cols, spec.FontSize) - return nil - } - if spec.FontSizesJSON != "" { - var m [][]int - if err := json.Unmarshal([]byte(spec.FontSizesJSON), &m); err != nil { - return fmt.Errorf("--font-sizes-json 解析失败: %w", err) - } - if err := checkMatrixShape(len(m), maxColLen2D(m), rows, cols, "font-sizes-json"); err != nil { - return err - } - toolArgs["fontSizes"] = m - } - return nil -} - -// apply2DString 处理一组 scalar + *-json 样式 flag,含枚举校验。传 enum=nil 跳过枚举校验。 -func apply2DString(scalar, jsonStr string, rows, cols int, flagName, toolKey string, enum map[string]bool, toolArgs map[string]any) error { - if scalar != "" && jsonStr != "" { - return fmt.Errorf("--%s 与 --%s-json 不能同时指定", flagName, flagName) - } - if scalar != "" { - if enum != nil && !enum[scalar] { - return fmt.Errorf("--%s 枚举非法: %s", flagName, scalar) - } - toolArgs[toolKey] = fillStringMatrix(rows, cols, scalar) - return nil - } - if jsonStr == "" { - return nil - } - var m [][]string - if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { - return fmt.Errorf("--%s-json 解析失败: %w", flagName, err) - } - if err := checkMatrixShape(len(m), maxColLenStr(m), rows, cols, flagName+"-json"); err != nil { - return err - } - if enum != nil { - for _, row := range m { - for _, v := range row { - if v == "" { - continue - } - if !enum[v] { - return fmt.Errorf("--%s-json 包含非法枚举值: %s", flagName, v) - } - } - } - } - toolArgs[toolKey] = m - return nil -} - func maxColLenStr(m [][]string) int { max := 0 for _, row := range m { @@ -261,6 +100,313 @@ func checkMatrixShape(gotRows, gotCols, wantRows, wantCols int, flagName string) return nil } +var ( + hAlignEnum = map[string]bool{"left": true, "center": true, "right": true, "general": true} + vAlignEnum = map[string]bool{"top": true, "middle": true, "bottom": true} + fontWeightEnum = map[string]bool{"bold": true, "normal": true} + wordWrapEnum = map[string]bool{"overflow": true, "clip": true, "autoWrap": true} + fontStyleEnum = map[string]bool{"normal": true, "italic": true} + fontLineEnum = map[string]bool{"none": true, "underline": true, "line-through": true} +) + +// styleSpec 描述一次 set-style 调用的样式参数(与 CLI flag / 批次 JSON 对齐)。 +type styleSpec struct { + BgColor string `json:"bgColor,omitempty"` + BgColorsJSON string `json:"bgColorsJson,omitempty"` + FontSize int `json:"fontSize,omitempty"` + FontSizesJSON string `json:"fontSizesJson,omitempty"` + HAlign string `json:"hAlign,omitempty"` + HAlignsJSON string `json:"hAlignsJson,omitempty"` + VAlign string `json:"vAlign,omitempty"` + VAlignsJSON string `json:"vAlignsJson,omitempty"` + FontColor string `json:"fontColor,omitempty"` + FontColorsJSON string `json:"fontColorsJson,omitempty"` + FontWeight string `json:"fontWeight,omitempty"` + FontWeightsJSON string `json:"fontWeightsJson,omitempty"` + WordWrap string `json:"wordWrap,omitempty"` + NumberFormat string `json:"numberFormat,omitempty"` + // 字体样式扩展(whole-range 标量) + FontStyle string `json:"fontStyle,omitempty"` // normal / italic + FontLine string `json:"fontLine,omitempty"` // none / underline / line-through + FontFamily string `json:"fontFamily,omitempty"` // 字体族名,如 Arial + // 四边边框 JSON(whole-range,per-cell 应用=网格),形如 + // {"top":{"style":"solid","color":"#000"},"bottom":{...},"left":{...},"right":{...}} + BorderStylesJSON string `json:"borderStylesJson,omitempty"` +} + +// borderStyleEnum 为引擎 BorderStyle 枚举(粗细已含在 style 内:solid=细/medium=中/thick=粗)。 +var borderStyleEnum = map[string]bool{ + "none": true, "dotted": true, "dashed": true, "solid": true, "medium": true, + "thick": true, "double": true, "hair": true, "dashDotDot": true, "dashDot": true, + "mediumDashDotDot": true, "slantDashDot": true, "mediumDashDot": true, "mediumDashed": true, +} + +var borderEdgeEnum = map[string]bool{"top": true, "bottom": true, "left": true, "right": true} + +// borderEdgeFields 是每条边的合法字段:服务端 borderStyles 的边对象只有这两个。 +var borderEdgeFields = []string{"style", "color"} + +// parseBorderStyles 解析 --border-styles-json,校验边名与 style 枚举,返回可注入 cells 的 borderStyles 对象。 +func parseBorderStyles(jsonStr string) (map[string]any, error) { + if jsonStr == "" { + return nil, nil + } + var raw map[string]map[string]any + if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { + return nil, fmt.Errorf("--border-styles-json 解析失败: %w", err) + } + if len(raw) == 0 { + return nil, fmt.Errorf("--border-styles-json 不能为空对象") + } + out := map[string]any{} + for edge, spec := range raw { + if !borderEdgeEnum[edge] { + return nil, fmt.Errorf("--border-styles-json 非法边名: %s(合法: top/bottom/left/right)", edge) + } + // 每条边只认 style / color。写错的键(colour)或类型不对的 color(123) + // 此前被静默忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / + // --styles 拒绝未知键是同一条不变量——部分丢样式比直接报错难发现得多。 + if err := rejectUnknownFields(spec, "边框每条边", borderEdgeFields); err != nil { + return nil, fmt.Errorf("--border-styles-json.%s: %w", edge, err) + } + styleVal, hasStyle := spec["style"] + if !hasStyle || styleVal == nil { + return nil, fmt.Errorf("--border-styles-json.%s 缺少 style", edge) + } + style, isStr := styleVal.(string) + if !isStr { + return nil, fmt.Errorf("--border-styles-json.%s.style 必须是字符串,实际是 %T", edge, styleVal) + } + if style == "" { + return nil, fmt.Errorf("--border-styles-json.%s 缺少 style", edge) + } + if !borderStyleEnum[style] { + return nil, fmt.Errorf("--border-styles-json.%s.style 非法: %s(合法: solid/medium/thick/dashed/dotted/double/hair/none/...)", edge, style) + } + edgeObj := map[string]any{"style": style} + if colorVal, ok := spec["color"]; ok && colorVal != nil { + color, isStr := colorVal.(string) + if !isStr { + return nil, fmt.Errorf("--border-styles-json.%s.color 必须是字符串,实际是 %T", edge, colorVal) + } + if color == "" { + return nil, fmt.Errorf("--border-styles-json.%s.color 不能为空字符串(不需要颜色就整个省掉该字段)", edge) + } + edgeObj["color"] = color + } + out[edge] = edgeObj + } + return out, nil +} + +// batchItem 是 batch-set-style 批次配置里的一项:嵌入 styleSpec,额外必填 sheetId + range。 +type batchItem struct { + SheetID string `json:"sheetId"` + Range string `json:"range"` + styleSpec +} + +// strGrid 返回一个 getter:给定 (i,j) 返回该格的字符串样式值与是否生效。 +// scalar 与 *-json 二选一;scalar 作用整个 range;json 逐格(空串表示该格不设置)。 +func strGrid(scalar, jsonStr, flagName string, enum map[string]bool, rows, cols int) (func(i, j int) (string, bool), error) { + if scalar != "" && jsonStr != "" { + return nil, fmt.Errorf("--%s 与 --%s-json 不能同时指定", flagName, flagName) + } + if scalar != "" { + if enum != nil && !enum[scalar] { + return nil, fmt.Errorf("--%s 枚举非法: %s", flagName, scalar) + } + return func(i, j int) (string, bool) { return scalar, true }, nil + } + if jsonStr == "" { + return nil, nil + } + var m [][]string + if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { + return nil, fmt.Errorf("--%s-json 解析失败: %w", flagName, err) + } + if err := checkMatrixShape(len(m), maxColLenStr(m), rows, cols, flagName+"-json"); err != nil { + return nil, err + } + if enum != nil { + for _, row := range m { + for _, v := range row { + if v != "" && !enum[v] { + return nil, fmt.Errorf("--%s-json 包含非法枚举值: %s", flagName, v) + } + } + } + } + return func(i, j int) (string, bool) { + if i < len(m) && j < len(m[i]) && m[i][j] != "" { + return m[i][j], true + } + return "", false + }, nil +} + +// intGrid 同 strGrid,用于 fontSize(0 表示未设置)。 +func intGrid(scalar int, jsonStr, flagName string, rows, cols int) (func(i, j int) (int, bool), error) { + if scalar != 0 && jsonStr != "" { + return nil, fmt.Errorf("--%s 与 --%s-json 不能同时指定", flagName, flagName) + } + if scalar != 0 { + if scalar < 0 { + return nil, fmt.Errorf("--%s 必须为正整数", flagName) + } + return func(i, j int) (int, bool) { return scalar, true }, nil + } + if jsonStr == "" { + return nil, nil + } + var m [][]int + if err := json.Unmarshal([]byte(jsonStr), &m); err != nil { + return nil, fmt.Errorf("--%s-json 解析失败: %w", flagName, err) + } + if err := checkMatrixShape(len(m), maxColLen2D(m), rows, cols, flagName+"-json"); err != nil { + return nil, err + } + return func(i, j int) (int, bool) { + if i < len(m) && j < len(m[i]) && m[i][j] != 0 { + return m[i][j], true + } + return 0, false + }, nil +} + +// buildStyleCells 依据 styleSpec 生成 set_cell_range 的 cells 矩阵(每格 {cellStyles:{...}} 或 {} 跳过)。 +func buildStyleCells(spec *styleSpec, rows, cols int) ([][]any, error) { + if rows <= 0 || cols <= 0 { + return nil, fmt.Errorf("range 行列数必须大于 0") + } + if rows > 1000 { + return nil, fmt.Errorf("单次样式更新行数上限为 1000(当前 %d 行)", rows) + } + if rows*cols > 30000 { + return nil, fmt.Errorf("单次样式更新单元格总数上限为 30000(当前 %d×%d=%d)", rows, cols, rows*cols) + } + + bgGet, err := strGrid(spec.BgColor, spec.BgColorsJSON, "bg-color", nil, rows, cols) + if err != nil { + return nil, err + } + sizeGet, err := intGrid(spec.FontSize, spec.FontSizesJSON, "font-size", rows, cols) + if err != nil { + return nil, err + } + hGet, err := strGrid(spec.HAlign, spec.HAlignsJSON, "h-align", hAlignEnum, rows, cols) + if err != nil { + return nil, err + } + vGet, err := strGrid(spec.VAlign, spec.VAlignsJSON, "v-align", vAlignEnum, rows, cols) + if err != nil { + return nil, err + } + fcGet, err := strGrid(spec.FontColor, spec.FontColorsJSON, "font-color", nil, rows, cols) + if err != nil { + return nil, err + } + fwGet, err := strGrid(spec.FontWeight, spec.FontWeightsJSON, "font-weight", fontWeightEnum, rows, cols) + if err != nil { + return nil, err + } + + // whole-range 标量样式 + if spec.WordWrap != "" && !wordWrapEnum[spec.WordWrap] { + return nil, fmt.Errorf("--word-wrap 枚举非法: %s(合法值: overflow / clip / autoWrap)", spec.WordWrap) + } + if spec.FontStyle != "" && !fontStyleEnum[spec.FontStyle] { + return nil, fmt.Errorf("--font-style 枚举非法: %s(合法值: normal / italic)", spec.FontStyle) + } + if spec.FontLine != "" && !fontLineEnum[spec.FontLine] { + return nil, fmt.Errorf("--font-line 枚举非法: %s(合法值: none / underline / line-through)", spec.FontLine) + } + borderObj, err := parseBorderStyles(spec.BorderStylesJSON) + if err != nil { + return nil, err + } + + anySet := bgGet != nil || sizeGet != nil || hGet != nil || vGet != nil || fcGet != nil || fwGet != nil || + spec.WordWrap != "" || spec.NumberFormat != "" || spec.FontStyle != "" || spec.FontLine != "" || spec.FontFamily != "" || + borderObj != nil + if !anySet { + return nil, fmt.Errorf("至少需要指定一个样式参数(--bg-color / --font-size / --h-align / --v-align / --font-color / --font-weight / --word-wrap / --number-format / --font-style / --font-line / --font-family / --border-styles-json 或对应的 *-json 形式)") + } + + cells := make([][]any, rows) + for i := 0; i < rows; i++ { + row := make([]any, cols) + for j := 0; j < cols; j++ { + cs := map[string]any{} + if bgGet != nil { + if v, ok := bgGet(i, j); ok { + cs["backgroundColor"] = v + } + } + if sizeGet != nil { + if v, ok := sizeGet(i, j); ok { + cs["fontSize"] = v + } + } + if hGet != nil { + if v, ok := hGet(i, j); ok { + cs["horizontalAlignment"] = v + } + } + if vGet != nil { + if v, ok := vGet(i, j); ok { + cs["verticalAlignment"] = v + } + } + if fcGet != nil { + if v, ok := fcGet(i, j); ok { + cs["fontColor"] = v + } + } + if fwGet != nil { + if v, ok := fwGet(i, j); ok { + cs["fontWeight"] = v + } + } + if spec.WordWrap != "" { + cs["wordWrap"] = spec.WordWrap + } + if spec.NumberFormat != "" { + cs["numberFormat"] = spec.NumberFormat + } + if spec.FontStyle != "" { + cs["fontStyle"] = spec.FontStyle + } + // font-line 作为单选:设定 textUnderline / textLineThrough 两个布尔 + switch spec.FontLine { + case "underline": + cs["textUnderline"] = true + cs["textLineThrough"] = false + case "line-through": + cs["textUnderline"] = false + cs["textLineThrough"] = true + case "none": + cs["textUnderline"] = false + cs["textLineThrough"] = false + } + if spec.FontFamily != "" { + cs["fontFamily"] = spec.FontFamily + } + + cell := map[string]any{} + if len(cs) > 0 { + cell["cellStyles"] = cs + } + if borderObj != nil { + cell["borderStyles"] = borderObj // 整区共用,per-cell 应用(网格) + } + row[j] = cell // 空 map = {} 跳过(保留原值) + } + cells[i] = row + } + return cells, nil +} + // readStyleSpecFromFlags 从 cobra flag 读取样式参数。 func readStyleSpecFromFlags(cmd *cobra.Command) *styleSpec { spec := &styleSpec{} @@ -278,56 +424,102 @@ func readStyleSpecFromFlags(cmd *cobra.Command) *styleSpec { spec.FontWeightsJSON, _ = cmd.Flags().GetString("font-weights-json") spec.WordWrap, _ = cmd.Flags().GetString("word-wrap") spec.NumberFormat, _ = cmd.Flags().GetString("number-format") + spec.FontStyle, _ = cmd.Flags().GetString("font-style") + spec.FontLine, _ = cmd.Flags().GetString("font-line") + spec.FontFamily, _ = cmd.Flags().GetString("font-family") + spec.BorderStylesJSON, _ = cmd.Flags().GetString("border-styles-json") return spec } -// bindStyleFlags 绑定共用的样式 flag。 -func bindStyleFlags(cmd *cobra.Command) { - cmd.Flags().String("bg-color", "", "背景色(#RRGGBB),一键刷整个 range;与 --bg-colors-json 二选一") - cmd.Flags().String("bg-colors-json", "", "背景色二维 JSON 数组,维度需与 --range 一致") - cmd.Flags().Int("font-size", 0, "字号,一键刷整个 range;与 --font-sizes-json 二选一") - cmd.Flags().String("font-sizes-json", "", "字号二维 JSON 数组,维度需与 --range 一致") - cmd.Flags().String("h-align", "", "水平对齐(left/center/right/general),一键刷整个 range") - cmd.Flags().String("h-aligns-json", "", "水平对齐二维 JSON 数组") - cmd.Flags().String("v-align", "", "垂直对齐(top/middle/bottom),一键刷整个 range") - cmd.Flags().String("v-aligns-json", "", "垂直对齐二维 JSON 数组") - cmd.Flags().String("font-color", "", "字体颜色(#RRGGBB),一键刷整个 range") - cmd.Flags().String("font-colors-json", "", "字体颜色二维 JSON 数组") - cmd.Flags().String("font-weight", "", "字体粗细(bold/normal),一键刷整个 range") - cmd.Flags().String("font-weights-json", "", "字体粗细二维 JSON 数组") - cmd.Flags().String("word-wrap", "", "换行方式(overflow/clip/autoWrap),整个 range 共用") - cmd.Flags().String("number-format", "", "数字格式 code(常用 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss;@ 为文本,适合长数字 ID)") +// styleFlagNames 是 bindStyleFlags 绑定的全部样式 flag。 +// set-style 与 batch-set-style 共用这套 flag,但 batch 的 --batch 模式下样式来自 +// 配置文件,需要据此判断用户是否传了不会生效的样式 flag。 +// 新增样式 flag 必须同步登记(TestStyleFlagNamesMatchBoundFlags 会盯住漂移)。 +var styleFlagNames = []string{ + "bg-color", "bg-colors-json", + "border-styles-json", + "font-color", "font-colors-json", + "font-family", "font-line", + "font-size", "font-sizes-json", + "font-style", + "font-weight", "font-weights-json", + "h-align", "h-aligns-json", + "number-format", + "v-align", "v-aligns-json", + "word-wrap", } -// newRangeSetStyleCmd 构造 dws sheet range set-style 命令。 +// changedStyleFlags 返回用户显式设置过的样式 flag(带 -- 前缀,顺序稳定)。 +func changedStyleFlags(cmd *cobra.Command) []string { + var out []string + for _, name := range styleFlagNames { + if cmd.Flags().Changed(name) { + out = append(out, "--"+name) + } + } + return out +} + +// bindStyleFlags 绑定共用的样式 flag。 +// 说明文案不能写死 --range:set-style 用 --range,batch-set-style 用 --ranges。 +func bindStyleFlags(cmd *cobra.Command) { + cmd.Flags().String("bg-color", "", "背景色(#RRGGBB),一键刷满目标区域;与 --bg-colors-json 二选一") + cmd.Flags().String("bg-colors-json", "", "背景色二维 JSON 数组,维度需与目标区域一致") + cmd.Flags().Int("font-size", 0, "字号,一键刷满目标区域;与 --font-sizes-json 二选一") + cmd.Flags().String("font-sizes-json", "", "字号二维 JSON 数组,维度需与目标区域一致") + cmd.Flags().String("h-align", "", "水平对齐(left/center/right/general),一键刷满目标区域") + cmd.Flags().String("h-aligns-json", "", "水平对齐二维 JSON 数组,维度需与目标区域一致") + cmd.Flags().String("v-align", "", "垂直对齐(top/middle/bottom),一键刷满目标区域") + cmd.Flags().String("v-aligns-json", "", "垂直对齐二维 JSON 数组,维度需与目标区域一致") + cmd.Flags().String("font-color", "", "字体颜色(#RRGGBB),一键刷满目标区域") + cmd.Flags().String("font-colors-json", "", "字体颜色二维 JSON 数组,维度需与目标区域一致") + cmd.Flags().String("font-weight", "", "字体粗细(bold/normal),一键刷满目标区域") + cmd.Flags().String("font-weights-json", "", "字体粗细二维 JSON 数组,维度需与目标区域一致") + cmd.Flags().String("word-wrap", "", "换行方式(overflow/clip/autoWrap),整个目标区域共用") + cmd.Flags().String("number-format", "", "数字格式 code(常用 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss;@ 为文本,适合长数字 ID)") + cmd.Flags().String("font-style", "", "字体样式(normal/italic),整个目标区域共用") + cmd.Flags().String("font-line", "", "字体线条(none/underline/line-through),整个目标区域共用") + cmd.Flags().String("font-family", "", "字体族(如 Arial / 微软雅黑),整个目标区域共用") + cmd.Flags().String("border-styles-json", "", `四边边框 JSON(整区共用,逐格应用=网格),如 '{"top":{"style":"solid","color":"#000"},"bottom":{"style":"solid"}}';style 取 solid/medium/thick/dashed/dotted/double/none 等(粗细含在 style 内);每条边只接受 style / color 两个键,写错的键或非字符串 color 直接报错`) +} + +// newRangeSetStyleCmd 构造 dws sheet range set-style 命令(走 set_cell_range 的 cellStyles 路径)。 func newRangeSetStyleCmd() *cobra.Command { cmd := &cobra.Command{ Use: "set-style", Short: "设置指定单元格区域的样式", - Long: `通过 update_range 透传样式字段,为指定 range 设置背景色 / 字号 / 对齐 / 字体颜色 / 字体粗细 / 换行方式 / 数字格式。 + Long: `为指定 range 设置背景色 / 字号 / 对齐 / 字体颜色 / 字体粗细 / 换行 / 数字格式 / 斜体 / 下划线删除线 / 字体族。 +底层走 set_cell_range 的 cellStyles(仅设样式、保留原值),纯样式写入可作用于含合并单元格的区域。 -每个样式维度提供两种用法,二选一: - --xxx 单值,一键刷整个 range(由 CLI 本地展开为二维数组) +每个样式维度(bg-color/font-size/h-align/v-align/font-color/font-weight)提供两种用法,二选一: + --xxx 单值,一键刷整个 range --xxx-json 二维 JSON 数组,逐单元格指定,维度需与 --range 一致 -单次调用建议:行数 ≤ 1000 且行×列 ≤ 5000;服务端硬限 rows ≤ 1000 且 rows×cols ≤ 30000。 +整区共用的标量样式(无 *-json 形式): + --word-wrap overflow/clip/autoWrap + --number-format 数字格式 code + --font-style normal/italic(斜体) + --font-line none/underline/line-through(下划线/删除线,单选) + --font-family 字体族名 + +单次调用建议:行数 ≤ 1000 且行×列 ≤ 30000(服务端硬限)。 数字格式(--number-format)传格式 code: - General 常规;@ 文本;0/0.00 普通数字;#,##0/#,##0.00 千分位;0%/0.00% 百分比;yyyy/m/d 日期;h:mm/h:mm:ss 时间;0.00E+00/##0.0E+0 科学计数;货币格式如 "¥"#,##0.00 或 $#,##0.00。 - 商品ID、规格ID、订单号、手机号等数字形态标识符请设置 @,否则 General/默认展示可能转为科学计数法。 -传入格式 code 字符串。`, + General 常规;@ 文本;0/0.00 普通数字;#,##0/#,##0.00 千分位;0%/0.00% 百分比;yyyy/m/d 日期;h:mm/h:mm:ss 时间;货币格式如 "¥"#,##0.00。 + 商品ID/订单号/手机号等数字形态标识符请设置 @,否则可能转为科学计数法。`, Example: ` # 给 A1:B3 打上黄底粗体居中 dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A1:B3" \ --bg-color "#FFF2CC" --font-weight bold --h-align center + # 斜体 + 下划线 + 指定字体 + dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A1:A10" \ + --font-style italic --font-line underline --font-family "微软雅黑" + # 给 C1:C5 逐单元格设置不同背景色 dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "C1:C5" \ --bg-colors-json '[["#FF0000"],["#00FF00"],["#0000FF"],["#FFFF00"],["#FF00FF"]]' - # 整片 range 启用自动换行 - dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A1:E10" --word-wrap autoWrap - - # 长数字标识符按文本展示,避免科学计数法 + # 长数字标识符按文本展示 dws sheet range set-style --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" --number-format "@"`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { @@ -341,16 +533,17 @@ func newRangeSetStyleCmd() *cobra.Command { if err != nil { return err } - toolArgs := map[string]any{ + spec := readStyleSpecFromFlags(cmd) + cells, err := buildStyleCells(spec, rows, cols) + if err != nil { + return err + } + return callMCPTool("set_cell_range", map[string]any{ "nodeId": node, "sheetId": sheetID, "rangeAddress": rangeAddr, - } - spec := readStyleSpecFromFlags(cmd) - if err := applyStyleSpec(spec, rows, cols, toolArgs); err != nil { - return err - } - return callMCPTool("update_range", toolArgs) + "cells": cells, + }) }, } cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") @@ -370,47 +563,43 @@ func newRangeSetStyleCmd() *cobra.Command { CLIPath: "sheet range set-style", PrimaryCLIPath: "sheet range set-style", }, - Description: "为指定范围统一设置背景、字体、对齐、换行或数字格式。", + Description: "为指定范围设置背景、字体、对齐、换行、数字格式、斜体/下划线或边框。", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "update_range"}, + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_cell_range"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "为指定范围统一设置背景、字体、对齐、换行或数字格式。", + AgentSummary: "为指定范围设置背景、字体、对齐、换行、数字格式、斜体/下划线或边框。", UseWhen: []string{"需要批量刷样式或数字格式(百分比/货币/日期)时"}, - AvoidWhen: []string{"写单元格值/公式用 range update;多区域不同样式配置用 range batch-set-style"}, + AvoidWhen: []string{"写单元格值/公式用 range update;多区域样式用 range batch-set-style"}, Examples: []string{"dws sheet range set-style --node --sheet-id --range \"B2:B10\" --number-format \"¥#,##0.00\""}, }, Parameters: []contract.ParamDecl{ - {Name: "bg-color", Property: "backgroundColors"}, - {Name: "bg-colors-json", Property: "backgroundColors"}, - {Name: "font-color", Property: "fontColors"}, - {Name: "font-colors-json", Property: "fontColors"}, - {Name: "font-size", Property: "fontSizes"}, - {Name: "font-sizes-json", Property: "fontSizes"}, - {Name: "font-weight", Property: "fontWeights"}, - {Name: "font-weights-json", Property: "fontWeights"}, - {Name: "h-align", Property: "horizontalAlignments"}, - {Name: "h-aligns-json", Property: "horizontalAlignments"}, {Name: "node", Property: "nodeId"}, {Name: "range", Property: "rangeAddress"}, - {Name: "v-align", Property: "verticalAlignments"}, - {Name: "v-aligns-json", Property: "verticalAlignments"}, + {Name: "sheet-id", Property: "sheetId"}, }, }, }) return cmd } -// newRangeBatchSetStyleCmd 构造 dws sheet range batch-set-style 命令(方案 A:CLI 侧顺序循环)。 +// newRangeBatchSetStyleCmd 构造 dws sheet range batch-set-style 命令。 +// 组装为**一次** batch_update 调用(服务端原子事务),对齐飞书 +cells-batch-set-style。 func newRangeBatchSetStyleCmd() *cobra.Command { cmd := &cobra.Command{ Use: "batch-set-style", - Short: "按配置文件批量设置样式", - Long: `读取一份 JSON 配置,逐条调用 update_range 给不同 range 设置样式,CLI 内部顺序执行。 + Short: "批量设置样式(服务端原子事务)", + Long: `批量为多个区域设置样式,CLI 组装为**一次** batch_update 调用,由服务端原子执行 +(底层每项走 set_cell_range 的 cellStyles/borderStyles)。任一项失败默认整批回滚。 -配置格式(JSON 数组,每个元素为一个批次项): +两种入口,二选一: + +1) --ranges(对齐飞书 +cells-batch-set-style):同一组样式应用到多个区域,支持跨工作表 + 每项必须带工作表前缀(格式 "SheetName!A1:B3"),最多 100 项;样式用与 set-style 相同的 flag。 + +2) --batch (每项可用不同样式):JSON 数组,每个元素为一个批次项 [ { "sheetId": "Sheet1", @@ -418,10 +607,11 @@ func newRangeBatchSetStyleCmd() *cobra.Command { "bgColor": "#FFF2CC", "fontSize": 12, "hAlign": "center", - "vAlign": "middle", - "fontColor": "#333333", "fontWeight": "bold", - "wordWrap": "autoWrap", + "fontStyle": "italic", + "fontLine": "underline", + "fontFamily": "Arial", + "borderStylesJson": "{\"top\":{\"style\":\"solid\"}}", "numberFormat": "General" }, { @@ -431,162 +621,67 @@ func newRangeBatchSetStyleCmd() *cobra.Command { } ] -每条记录执行与 set-style 一致的校验(至少一项样式字段 + rows≤1000 + rows×cols≤30000 + 枚举)。 -某条失败时默认停止后续批次;要继续执行请加 --continue-on-error。`, - Example: ` dws sheet range batch-set-style --node NODE_ID --batch ./styles.json +每项执行与 set-style 一致的本地校验(至少一项样式字段 + rows≤1000 + rows×cols≤30000 + 枚举), +全部校验通过后才下发,避免部分生效。 +批量上限:最多 100 个区域,且**所有区域累计**不超过 200000 个单元格(一次请求要把全部 +单元格矩阵建好,累计量才是真正的峰值约束)。超出请拆成多次调用。 +--continue-on-error 透传给服务端 batch_update:默认严格事务(失败整批回滚),传入后遇失败继续执行其余项。`, + Example: ` # 同一组样式刷多个区域(可跨工作表),一次原子提交 + dws sheet range batch-set-style --node NODE_ID \ + --ranges '["Sheet1!A1:B2","Sheet2!D1:D10"]' \ + --bg-color "#FFF2CC" --font-weight bold --font-family "微软雅黑" + + # 每项不同样式:用配置文件 + dws sheet range batch-set-style --node NODE_ID --batch ./styles.json dws sheet range batch-set-style --node NODE_ID --batch ./styles.json --continue-on-error`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "node", "batch"); err != nil { + if err := validateRequiredFlags(cmd, "node"); err != nil { return err } node := mustGetFlag(cmd, "node") - batchPath := mustGetFlag(cmd, "batch") - continueOnErr, _ := cmd.Flags().GetBool("continue-on-error") - jsonMode := deps.Caller.Format() == "json" - ctx := context.Background() - var jsonResults []any + rangesStr, _ := cmd.Flags().GetString("ranges") + batchPath, _ := cmd.Flags().GetString("batch") + if rangesStr == "" && batchPath == "" { + return fmt.Errorf("--ranges 与 --batch 至少提供一个") + } + if rangesStr != "" && batchPath != "" { + return fmt.Errorf("--ranges 与 --batch 二选一,不能同时指定") + } + // --batch 模式每项样式来自配置文件;命令行样式 flag 不会生效, + // 静默忽略会让用户以为刷上了,所以直接拒。 + if batchPath != "" { + if used := changedStyleFlags(cmd); len(used) > 0 { + return fmt.Errorf("--batch 模式下样式来自配置文件,%s 不会生效;请把样式写进配置文件,或改用 --ranges", + strings.Join(used, " / ")) + } + } - data, err := os.ReadFile(batchPath) + var operations []any + var err error + if rangesStr != "" { + operations, err = buildBatchStyleOpsFromRanges(cmd, rangesStr) + } else { + operations, err = buildBatchStyleOpsFromFile(batchPath) + } if err != nil { - return fmt.Errorf("读取 --batch 文件失败: %w", err) - } - var items []batchItem - if err := json.Unmarshal(data, &items); err != nil { - return fmt.Errorf("--batch JSON 解析失败: %w", err) - } - if len(items) == 0 { - return fmt.Errorf("--batch 配置为空") + return err } - total := len(items) - var firstErr error - var failed int - for i, item := range items { - if item.SheetID == "" || item.Range == "" { - err := fmt.Errorf("第 %d/%d 条缺少 sheetId 或 range", i+1, total) - fmt.Fprintln(os.Stderr, err) - failed++ - if firstErr == nil { - firstErr = err - } - if !continueOnErr { - break - } - continue - } - rows, cols, err := parseA1Range(item.Range) - if err != nil { - err = fmt.Errorf("第 %d/%d 条 range 解析失败: %w", i+1, total, err) - fmt.Fprintln(os.Stderr, err) - failed++ - if firstErr == nil { - firstErr = err - } - if !continueOnErr { - break - } - continue - } - toolArgs := map[string]any{ - "nodeId": node, - "sheetId": item.SheetID, - "rangeAddress": item.Range, - } - if err := applyStyleSpec(&item.styleSpec, rows, cols, toolArgs); err != nil { - err = fmt.Errorf("第 %d/%d 条样式校验失败: %w", i+1, total, err) - fmt.Fprintln(os.Stderr, err) - failed++ - if firstErr == nil { - firstErr = err - } - if !continueOnErr { - break - } - continue - } - fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range) - if deps.Caller.DryRun() { - // JSON mode emits one aggregate preview below. Human formats - // reuse the shared per-call preview printer. - if !jsonMode { - _ = callMCPTool("update_range", toolArgs) - } - if jsonMode { - jsonResults = append(jsonResults, map[string]any{ - "index": i + 1, - "sheetId": item.SheetID, - "range": item.Range, - "ok": true, - "dryRun": true, - "tool": "update_range", - "arguments": toolArgs, - }) - } - continue - } - if jsonMode { - text, cerr := callMCPToolReturnText(ctx, "update_range", toolArgs) - entry := map[string]any{"index": i + 1, "sheetId": item.SheetID, "range": item.Range} - if cerr != nil { - entry["ok"] = false - entry["error"] = cerr.Error() - jsonResults = append(jsonResults, entry) - cerr = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, cerr) - fmt.Fprintln(os.Stderr, cerr) - failed++ - if firstErr == nil { - firstErr = cerr - } - if !continueOnErr { - break - } - continue - } - var parsed any - if json.Unmarshal([]byte(text), &parsed) == nil { - entry["result"] = parsed - } else { - entry["result"] = text - } - entry["ok"] = true - jsonResults = append(jsonResults, entry) - continue - } - if err := callMCPTool("update_range", toolArgs); err != nil { - err = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, err) - fmt.Fprintln(os.Stderr, err) - failed++ - if firstErr == nil { - firstErr = err - } - if !continueOnErr { - break - } - } + toolArgs := map[string]any{ + "nodeId": node, + "operations": operations, } - fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed) - if jsonMode { - if err := deps.Out.PrintJSON(map[string]any{ - "total": total, - "failed": failed, - "results": jsonResults, - "success": failed == 0, - }); err != nil { - return err - } + if continueOnErr, _ := cmd.Flags().GetBool("continue-on-error"); continueOnErr { + toolArgs["continueOnError"] = true } - if failed > 0 && !continueOnErr { - return firstErr - } - if failed > 0 { - return fmt.Errorf("batch-set-style 共 %d 条失败(首个错误:%v)", failed, firstErr) - } - return nil + return callMCPTool("batch_update", toolArgs) }, } cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") - cmd.Flags().String("batch", "", "批次配置 JSON 文件路径 (必填)") - cmd.Flags().Bool("continue-on-error", false, "遇到失败时继续执行后续条目(默认遇错即停)") + cmd.Flags().String("ranges", "", `目标区域 JSON 数组,每项须带工作表前缀且最多 100 项,如 '["Sheet1!A1:B2","Sheet2!D1:D10"]'(与 --batch 二选一,同一组样式应用到所有区域)`) + cmd.Flags().String("batch", "", "批次配置 JSON 文件路径(与 --ranges 二选一,每项可用不同样式)") + cmd.Flags().Bool("continue-on-error", false, "遇到失败时继续执行其余项(默认严格事务,整批回滚)") + bindStyleFlags(cmd) DeclareLeafMetadata(cmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -600,17 +695,17 @@ func newRangeBatchSetStyleCmd() *cobra.Command { CLIPath: "sheet range batch-set-style", PrimaryCLIPath: "sheet range batch-set-style", }, - Description: "按 JSON 配置文件顺序批量设置多个区域样式。", + Description: "批量为多个区域设置样式,组装为一次 batch_update 原子提交。", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", - Reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.", + Reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.", }, Selection: contract.SelectionSpec{ - AgentSummary: "按 JSON 配置文件顺序批量设置多个区域样式。", - UseWhen: []string{"多个区域样式不同、希望用配置文件一次提交时"}, + AgentSummary: "批量为多个区域设置样式,组装为一次 batch_update 原子提交。", + UseWhen: []string{"同一组样式要刷多个区域(可跨工作表),或多个区域样式不同需一次原子提交时"}, AvoidWhen: []string{"单一区域统一样式用 range set-style"}, - Examples: []string{"dws sheet range batch-set-style --node --batch ./styles.json"}, + Examples: []string{"dws sheet range batch-set-style --node --ranges '[\"Sheet1!A1:B2\"]' --bg-color \"#FFF2CC\""}, }, Parameters: []contract.ParamDecl{ {Name: "node", Property: "nodeId"}, @@ -619,3 +714,134 @@ func newRangeBatchSetStyleCmd() *cobra.Command { }) return cmd } + +// maxBatchStyleRanges 与飞书 +cells-batch-set-style 的 --ranges 上限一致。 +const maxBatchStyleRanges = 100 + +// maxBatchStyleCells 是一次 fan-out batch 累计可物化的单元格上限(对齐飞书 +// maxStampMatrixCells / checkBatchStampBudget)。batch 会把每个 range 的 cells +// 矩阵全部建好再序列化成一个请求,所以真正的峰值约束是**跨 range 的总和**, +// 而不是 buildStyleCells 里的单区域上限:100 个区域各 30000 格 = 300 万个 +// cell map。单区域上限继续由 buildStyleCells 按服务端硬限(30000)把关。 +const maxBatchStyleCells = 200000 + +// addBatchStyleCells 累加待物化的单元格数,超限即报错。 +// 必须在 buildStyleCells 分配矩阵**之前**调用,否则超限的那份已经建出来了。 +func addBatchStyleCells(total *int64, rows, cols int) error { + *total += int64(rows) * int64(cols) + if *total > maxBatchStyleCells { + return fmt.Errorf("所有区域累计展开 %d 个单元格,超过 %d 的安全上限;请减少区域数量或缩小区域范围", *total, maxBatchStyleCells) + } + return nil +} + +// splitSheetPrefixedRange 拆分 "SheetName!A1:B3" → (sheetName, rangeAddr)。 +// 两侧都必须在**修剪之后**仍非空:只按原始串里 ! 的位置判断会放过 " !A1:B2", +// 修剪后工作表名成了空串,操作却照样带着 sheetId:"" 提交 —— 服务端要么让整批 +// batch_update 失败,要么更危险地落到默认工作表而不是用户指定的那张表。 +func splitSheetPrefixedRange(rng string, idx int) (string, string, error) { + i := strings.Index(rng, "!") + if i < 0 { + return "", "", fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", idx, rng) + } + sheetName := strings.TrimSpace(rng[:i]) + rangeAddr := strings.TrimSpace(rng[i+1:]) + if sheetName == "" || rangeAddr == "" { + return "", "", fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", idx, rng) + } + return sheetName, rangeAddr, nil +} + +// buildBatchStyleOpsFromRanges 把「一组样式 + 多个带前缀 range」翻成 batch_update 的 operations。 +func buildBatchStyleOpsFromRanges(cmd *cobra.Command, rangesStr string) ([]any, error) { + var ranges []string + if err := json.Unmarshal([]byte(rangesStr), &ranges); err != nil { + return nil, fmt.Errorf("--ranges JSON 解析失败: %w\n hint: --ranges 必须是 JSON 字符串数组,如 '[\"Sheet1!A1:B3\"]'", err) + } + if len(ranges) == 0 { + return nil, fmt.Errorf("--ranges 不能为空数组") + } + if len(ranges) > maxBatchStyleRanges { + return nil, fmt.Errorf("--ranges 最多 %d 项,当前 %d 项", maxBatchStyleRanges, len(ranges)) + } + spec := readStyleSpecFromFlags(cmd) + operations := make([]any, 0, len(ranges)) + var totalCells int64 + for i, rng := range ranges { + sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i) + if err != nil { + return nil, err + } + rows, cols, err := parseA1Range(rangeAddr) + if err != nil { + return nil, fmt.Errorf("--ranges[%d] (%q) 解析失败: %w", i, rng, err) + } + if err := addBatchStyleCells(&totalCells, rows, cols); err != nil { + return nil, fmt.Errorf("--ranges[%d] (%q): %w", i, rng, err) + } + cells, err := buildStyleCells(spec, rows, cols) + if err != nil { + return nil, fmt.Errorf("--ranges[%d] (%q): %w", i, rng, err) + } + operations = append(operations, map[string]any{ + "toolName": "set_cell_range", + "input": map[string]any{ + "sheetId": sheetName, + "rangeAddress": rangeAddr, + "cells": cells, + }, + }) + } + return operations, nil +} + +// buildBatchStyleOpsFromFile 把配置文件里的多条「range + 各自样式」翻成 batch_update 的 operations。 +func buildBatchStyleOpsFromFile(batchPath string) ([]any, error) { + data, err := os.ReadFile(batchPath) + if err != nil { + return nil, fmt.Errorf("读取 --batch 文件失败: %w", err) + } + var items []batchItem + if err := json.Unmarshal(data, &items); err != nil { + return nil, fmt.Errorf("--batch JSON 解析失败: %w", err) + } + if len(items) == 0 { + return nil, fmt.Errorf("--batch 配置为空") + } + total := len(items) + if total > maxBatchStyleRanges { + return nil, fmt.Errorf("--batch 最多 %d 条,当前 %d 条", maxBatchStyleRanges, total) + } + operations := make([]any, 0, total) + var totalCells int64 + for i, item := range items { + // 判空看修剪后的值:" " 这类纯空白与漏填等价,放过去就是一次 sheetId 为 + // 空白的 set_cell_range。但下发仍用原值:sheetId 可以是工作表**名**,名字 + // 允许带首尾空格,这里替用户修剪就会指向另一张表(或找不到表)。--ranges + // 那条路径受 "Name!A1" 的书写形式所限只能修剪,需要精确名字时请走 --batch。 + if strings.TrimSpace(item.SheetID) == "" || strings.TrimSpace(item.Range) == "" { + return nil, fmt.Errorf("--batch 第 %d/%d 条缺少 sheetId 或 range", i+1, total) + } + rows, cols, err := parseA1Range(item.Range) + if err != nil { + return nil, fmt.Errorf("--batch 第 %d/%d 条 range 解析失败: %w", i+1, total, err) + } + if err := addBatchStyleCells(&totalCells, rows, cols); err != nil { + return nil, fmt.Errorf("--batch 第 %d/%d 条: %w", i+1, total, err) + } + spec := item.styleSpec + cells, err := buildStyleCells(&spec, rows, cols) + if err != nil { + return nil, fmt.Errorf("--batch 第 %d/%d 条样式校验失败: %w", i+1, total, err) + } + operations = append(operations, map[string]any{ + "toolName": "set_cell_range", + "input": map[string]any{ + "sheetId": item.SheetID, + "rangeAddress": item.Range, + "cells": cells, + }, + }) + } + return operations, nil +} diff --git a/internal/helpers/sheet_style_batch_budget_test.go b/internal/helpers/sheet_style_batch_budget_test.go new file mode 100644 index 00000000..babe7176 --- /dev/null +++ b/internal/helpers/sheet_style_batch_budget_test.go @@ -0,0 +1,376 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +package helpers + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。 +// +// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的 +// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。 +// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。 +const budgetTestRange = "A1:Z1000" + +func budgetTestRanges(n int) []string { + out := make([]string, 0, n) + for i := 0; i < n; i++ { + out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange)) + } + return out +} + +func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command { + t.Helper() + cmd := newRangeBatchSetStyleCmd() + if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil { + t.Fatalf("set --bg-color: %v", err) + } + return cmd +} + +func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) { + // 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。 + within, err := json.Marshal(budgetTestRanges(7)) + if err != nil { + t.Fatal(err) + } + ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within)) + if err != nil { + t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err) + } + if len(ops) != 7 { + t.Fatalf("operations = %d, want 7", len(ops)) + } + + // 第 8 个把累计推到 208000,越过 200000 上限。 + over, err := json.Marshal(budgetTestRanges(8)) + if err != nil { + t.Fatal(err) + } + _, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over)) + if err == nil { + t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝") + } + for _, want := range []string{"--ranges[7]", "208000", "200000"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want contains %q", err, want) + } + } +} + +func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) { + writeBatch := func(t *testing.T, items []map[string]any) string { + t.Helper() + data, err := json.Marshal(items) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "styles.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write batch fixture: %v", err) + } + return path + } + item := func(i int) map[string]any { + return map[string]any{ + "sheetId": fmt.Sprintf("Sheet%d", i+1), + "range": budgetTestRange, + "fontWeight": "bold", + } + } + + // 累计预算:8 条 × 26000 = 208000 > 200000。 + overBudget := make([]map[string]any, 0, 8) + for i := 0; i < 8; i++ { + overBudget = append(overBudget, item(i)) + } + _, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget)) + if err == nil { + t.Fatal("--batch 累计 208000 格必须被拒绝") + } + for _, want := range []string{"第 8/8 条", "208000", "200000"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want contains %q", err, want) + } + } + + // 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。 + tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1) + for i := 0; i < maxBatchStyleRanges+1; i++ { + tooMany = append(tooMany, map[string]any{ + "sheetId": "Sheet1", "range": "A1", "fontWeight": "bold", + }) + } + _, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany)) + if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") { + t.Fatalf("error = %v, want --batch item cap", err) + } + + // 预算内的正常配置仍然照常展开。 + ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)})) + if err != nil { + t.Fatalf("2 条应在预算内: %v", err) + } + if len(ops) != 2 { + t.Fatalf("operations = %d, want 2", len(ops)) + } +} + +func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) { + var total int64 + if err := addBatchStyleCells(&total, 1000, 26); err != nil { + t.Fatalf("first range: %v", err) + } + if total != 26000 { + t.Fatalf("total = %d, want 26000", total) + } + // 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。 + var single int64 + if err := addBatchStyleCells(&single, 1000, 1000); err == nil { + t.Fatal("1000x1000=1000000 必须超出累计预算") + } +} + +// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致: +// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。 +func TestStyleFlagNamesMatchBoundFlags(t *testing.T) { + probe := &cobra.Command{Use: "probe"} + bindStyleFlags(probe) + + bound := map[string]bool{} + probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true }) + + listed := map[string]bool{} + for _, name := range styleFlagNames { + if listed[name] { + t.Fatalf("styleFlagNames 重复登记 %q", name) + } + listed[name] = true + if !bound[name] { + t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name) + } + } + for name := range bound { + if !listed[name] { + t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name) + } + } +} + +func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + batchPath := filepath.Join(t.TempDir(), "styles.json") + if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil { + t.Fatalf("write batch fixture: %v", err) + } + + caller := &sheetStyleDryRunCaller{format: "json"} + InitDeps(caller) + + cmd := newRangeBatchSetStyleCmd() + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"}) + err := cmd.Execute() + if err == nil { + t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略") + } + for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want contains %q", err, want) + } + } + if caller.calls != 0 { + t.Fatalf("remote CallTool count = %d, want 0", caller.calls) + } +} + +// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。 +func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) { + tooMany := make([]string, 0, maxBatchStyleRanges+1) + for i := 0; i <= maxBatchStyleRanges; i++ { + tooMany = append(tooMany, `"Sheet1!A1"`) + } + + for _, tc := range []struct { + name string + ranges string + style bool + want string + }{ + {name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"}, + {name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"}, + { + name: "over-range-cap", + ranges: "[" + strings.Join(tooMany, ",") + "]", + style: true, + want: "--ranges 最多 100 项", + }, + { + name: "missing-sheet-prefix", + ranges: `["A1:B2"]`, + style: true, + want: "必须包含工作表前缀", + }, + { + name: "blank-sheet-prefix", + ranges: `[" !A1:B2"]`, + style: true, + want: "必须包含工作表前缀", + }, + { + name: "blank-range-after-prefix", + ranges: `["Sheet1! "]`, + style: true, + want: "必须包含工作表前缀", + }, + { + name: "bad-range-address", + ranges: `["Sheet1!zz"]`, + style: true, + want: "解析失败", + }, + { + name: "no-style-flag", + ranges: `["Sheet1!A1:B2"]`, + style: false, + want: "至少需要指定一个样式参数", + }, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := newRangeBatchSetStyleCmd() + if tc.style { + if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil { + t.Fatalf("set --bg-color: %v", err) + } + } + _, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + }) + } +} + +// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。 +// +// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着 +// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是 +// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理, +// 它此前只挡 == ""。三条路径都必须在发请求之前失败。 +func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + batchDir := t.TempDir() + writeBatch := func(t *testing.T, name, body string) string { + t.Helper() + p := filepath.Join(batchDir, name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatalf("write batch fixture: %v", err) + } + return p + } + + for _, tc := range []struct { + name string + cmd func() *cobra.Command + args func(t *testing.T) []string + want string + }{ + { + name: "set-style-ranges-blank-sheet", + cmd: newRangeBatchSetStyleCmd, + args: func(*testing.T) []string { + return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"} + }, + want: "必须包含工作表前缀", + }, + { + name: "set-style-batch-blank-sheet-id", + cmd: newRangeBatchSetStyleCmd, + args: func(t *testing.T) []string { + p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`) + return []string{"--node", "NODE_ID", "--batch", p} + }, + want: "缺少 sheetId 或 range", + }, + { + name: "set-style-batch-blank-range", + cmd: newRangeBatchSetStyleCmd, + args: func(t *testing.T) []string { + p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`) + return []string{"--node", "NODE_ID", "--batch", p} + }, + want: "缺少 sheetId 或 range", + }, + { + name: "batch-clear-ranges-blank-sheet", + cmd: rangeBatchClearCoverageCommand, + args: func(*testing.T) []string { + return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`} + }, + want: "必须包含工作表前缀", + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &sheetStyleDryRunCaller{format: "json"} + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + + cmd := tc.cmd() + cmd.SetArgs(tc.args(t)) + cmd.SilenceUsage, cmd.SilenceErrors = true, true + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("remote CallTool count = %d, want 0", caller.calls) + } + }) + } +} + +// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾 +// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。 +func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) { + p := filepath.Join(t.TempDir(), "padded.json") + if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil { + t.Fatalf("write batch fixture: %v", err) + } + + ops, err := buildBatchStyleOpsFromFile(p) + if err != nil { + t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err) + } + if len(ops) != 1 { + t.Fatalf("operations = %d, want 1", len(ops)) + } + op, _ := ops[0].(map[string]any) + input, _ := op["input"].(map[string]any) + if input["sheetId"] != " Sheet1 " { + t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ") + } + if input["rangeAddress"] != " A1:B2 " { + t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ") + } +} diff --git a/internal/helpers/sheet_style_coverage_more_test.go b/internal/helpers/sheet_style_coverage_more_test.go index 3665d27c..97648d52 100644 --- a/internal/helpers/sheet_style_coverage_more_test.go +++ b/internal/helpers/sheet_style_coverage_more_test.go @@ -12,17 +12,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) { for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} { _, _, _ = parseA1Range(raw) } - _ = fillStringMatrix(2, 2, "x") - _ = fillIntMatrix(2, 2, 1) - for _, spec := range []*styleSpec{ - {FontSize: 1, FontSizesJSON: `[[1]]`}, - {FontSize: -1}, - {FontSize: 12}, - {FontSizesJSON: `{`}, - {FontSizesJSON: `[[1,2]]`}, - {FontSizesJSON: `[[1]]`}, + for _, tc := range []struct { + scalar int + jsonStr string + }{ + {1, `[[1]]`}, {-1, ""}, {12, ""}, {0, `{`}, {0, `[[1,2]]`}, {0, `[[1]]`}, {0, ""}, } { - _ = applyFontSize(spec, 1, 1, map[string]any{}) + if get, err := intGrid(tc.scalar, tc.jsonStr, "font-size", 1, 1); err == nil && get != nil { + _, _ = get(0, 0) + _, _ = get(9, 9) + } } for _, tc := range []struct { scalar, raw string @@ -32,7 +31,17 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) { {"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil}, {"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum}, } { - _ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{}) + if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil { + _, _ = get(0, 0) + _, _ = get(9, 9) + } + } + for _, raw := range []string{"", `{`, `{}`, `{"nope":{"style":"solid"}}`, `{"top":{}}`, + `{"top":{"style":"bogus"}}`, `{"top":{"style":"solid"}}`, `{"top":{"style":"solid","color":"#000"}}`} { + _, _ = parseBorderStyles(raw) + } + for _, raw := range []string{"Sheet1!A1:B2", "A1:B2", "Sheet1!", "!A1"} { + _, _, _ = splitSheetPrefixedRange(raw, 0) } _ = maxColLenStr([][]string{{}, {"a", "b"}}) _ = maxColLen2D([][]int{{}, {1, 2}}) @@ -46,8 +55,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) { {styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31}, {styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1}, {styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1}, + {styleSpec{FontStyle: "bogus"}, 1, 1}, {styleSpec{FontStyle: "italic"}, 1, 1}, + {styleSpec{FontLine: "bogus"}, 1, 1}, {styleSpec{FontLine: "underline"}, 1, 1}, + {styleSpec{FontLine: "line-through"}, 1, 1}, {styleSpec{FontLine: "none"}, 1, 1}, + {styleSpec{FontFamily: "Arial"}, 1, 1}, + {styleSpec{BorderStylesJSON: `{"top":{"style":"solid"}}`}, 1, 1}, + {styleSpec{BorderStylesJSON: `{`}, 1, 1}, + {styleSpec{BgColor: "#FFF", FontSize: 12, HAlign: "center", VAlign: "top", + FontColor: "#000", FontWeight: "bold"}, 2, 2}, } { - _ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{}) + _, _ = buildStyleCells(&tc.spec, tc.rows, tc.cols) } } diff --git a/internal/helpers/sheet_style_test.go b/internal/helpers/sheet_style_test.go index d044e020..61b0734c 100644 --- a/internal/helpers/sheet_style_test.go +++ b/internal/helpers/sheet_style_test.go @@ -62,16 +62,22 @@ func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testin if err := json.Unmarshal(output.Bytes(), &payload); err != nil { t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview) } - results, _ := payload["results"].([]any) - if len(results) != 1 { - t.Fatalf("JSON dry-run results = %#v", payload["results"]) + // batch-set-style 现在组装为一次 batch_update 原子提交, + // dry-run 因此是单条标准记录,而不是本地循环产生的 results 数组。 + if payload["tool"] != "batch_update" { + t.Fatalf("JSON dry-run tool = %#v, want batch_update", payload["tool"]) } - entry, _ := results[0].(map[string]any) - if entry["dryRun"] != true || entry["tool"] != "update_range" { - t.Fatalf("JSON dry-run result = %#v", entry) + args, _ := payload["arguments"].(map[string]any) + ops, _ := args["operations"].([]any) + if len(ops) != 1 { + t.Fatalf("JSON dry-run operations = %#v, want 1 item", args["operations"]) + } + op, _ := ops[0].(map[string]any) + if op["toolName"] != "set_cell_range" { + t.Fatalf("JSON dry-run operation toolName = %#v", op["toolName"]) } } else { - for _, want := range []string{"Tool:", "update_range", "Arguments:"} { + for _, want := range []string{"Tool:", "batch_update", "set_cell_range", "Arguments:"} { if !strings.Contains(preview, want) { t.Fatalf("dry-run preview missing %q:\n%s", want, preview) } @@ -104,3 +110,104 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) { t.Fatalf("remote CallTool count = %d, want 0", caller.calls) } } + +// 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默 +// 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键 +// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用 +// parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。 +func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) { + for _, tc := range []struct { + name string + json string + want string + }{ + {"near-miss-color-key", `{"top":{"style":"solid","colour":"#f00"}}`, `未知字段 "colour"`}, + {"unknown-key", `{"top":{"style":"solid","width":2}}`, `未知字段 "width"`}, + {"camel-case-alias-hinted", `{"top":{"style":"solid","Color":"#f00"}}`, `应为 "color"`}, + {"color-not-string", `{"top":{"style":"solid","color":123}}`, "color 必须是字符串,实际是 float64"}, + {"color-empty-string", `{"top":{"style":"solid","color":""}}`, "color 不能为空字符串"}, + {"style-not-string", `{"top":{"style":123}}`, "style 必须是字符串,实际是 float64"}, + {"style-empty-string", `{"top":{"style":""}}`, "缺少 style"}, + {"style-missing", `{"top":{"color":"#f00"}}`, "缺少 style"}, + {"style-null", `{"top":{"style":null}}`, "缺少 style"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := parseBorderStyles(tc.json) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want contains %q", err, tc.want) + } + if err != nil && !strings.Contains(err.Error(), "--border-styles-json.top") { + t.Fatalf("err = %v, want the edge name in the message", err) + } + }) + } + + // 合法形态不能被误拒:只给 style、给 style+color、显式 null color(等同省略) + for _, tc := range []struct { + name string + json string + wantColor any + }{ + {"style-only", `{"top":{"style":"solid"}}`, nil}, + {"style-and-color", `{"top":{"style":"solid","color":"#FF0000"}}`, "#FF0000"}, + {"explicit-null-color", `{"top":{"style":"solid","color":null}}`, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + out, err := parseBorderStyles(tc.json) + if err != nil { + t.Fatalf("parseBorderStyles(%s) = %v, want accepted", tc.json, err) + } + edge, _ := out["top"].(map[string]any) + if edge["style"] != "solid" { + t.Fatalf("style = %#v, want solid", edge["style"]) + } + if edge["color"] != tc.wantColor { + t.Fatalf("color = %#v, want %#v", edge["color"], tc.wantColor) + } + }) + } +} + +// 单区域与 batch 两条路径:非法边框配置必须在组装/下发之前失败。 +func TestBorderStyleRejectionHappensBeforeAnyRemoteCall(t *testing.T) { + const badBorder = `{"top":{"style":"solid","colour":"#f00"}}` + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + batchPath := filepath.Join(t.TempDir(), "styles.json") + fixture := `[{"sheetId":"Sheet1","range":"A1:B2","borderStylesJson":"{\"top\":{\"style\":\"solid\",\"colour\":\"#f00\"}}"}]` + if err := os.WriteFile(batchPath, []byte(fixture), 0o600); err != nil { + t.Fatalf("write batch fixture: %v", err) + } + + for _, tc := range []struct { + name string + args []string + }{ + {"single-range", []string{"--node", "NODE_ID", "--sheet-id", "S", "--range", "A1:B2", "--border-styles-json", badBorder}}, + {"batch-ranges", []string{"--node", "NODE_ID", "--ranges", `["Sheet1!A1:B2"]`, "--border-styles-json", badBorder}}, + {"batch-file", []string{"--node", "NODE_ID", "--batch", batchPath}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &sheetStyleDryRunCaller{format: "json"} + InitDeps(caller) + var output bytes.Buffer + deps.Out.w = &output + deps.Out.errW = &output + + cmd := newRangeSetStyleCmd() + if tc.name != "single-range" { + cmd = newRangeBatchSetStyleCmd() + } + cmd.SetArgs(tc.args) + cmd.SilenceUsage, cmd.SilenceErrors = true, true + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), `未知字段 "colour"`) { + t.Fatalf("err = %v, want the unknown border field rejected", err) + } + if caller.calls != 0 { + t.Fatalf("remote CallTool count = %d, want 0", caller.calls) + } + }) + } +} diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go index e95b1c08..d34b57eb 100644 --- a/internal/helpers/small_remaining_coverage_test.go +++ b/internal/helpers/small_remaining_coverage_test.go @@ -12,7 +12,7 @@ import ( ) func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T) { - if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil { + if _, err := buildStyleCells(&styleSpec{FontColorsJSON: "{"}, 1, 1); err == nil { t.Fatal("invalid font colors JSON returned nil") } diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go index 79fcd2d3..17546ab2 100644 --- a/internal/interfacesnapshot/compare.go +++ b/internal/interfacesnapshot/compare.go @@ -73,6 +73,10 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report { // become required; an existing path may not gain a new required flag, // - new commands and flags are allowed. // +// The single exception to the type rule is an individually reviewed migration +// listed in reviewedFlagTypeChanges, and only when nothing else about the flag +// changed. See reviewed.go. +// // Comparing the effective local + inherited set is intentional. It catches a // persistent flag whose scope is accidentally narrowed to its declaring // command, while allowing a local flag to move to an ancestor without breaking @@ -183,13 +187,20 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand, continue } if oldFlag.Type != newFlag.Type { - result.Blocking = append(result.Blocking, Change{ - Kind: "flag_type_changed", - Path: acceptedPath, - Flag: name, - Before: oldFlag.Type, - After: newFlag.Type, - }) + // Resolve the exemption against the canonical path, not acceptedPath. + // An aliased command reaches this function once per accepted spelling, + // so keying on acceptedPath would let every alias spelling re-report a + // reviewed migration. + if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) || + flagContractOtherwiseChanged(oldFlag, newFlag) { + result.Blocking = append(result.Blocking, Change{ + Kind: "flag_type_changed", + Path: acceptedPath, + Flag: name, + Before: oldFlag.Type, + After: newFlag.Type, + }) + } } if !oldFlag.Required && newFlag.Required { result.Blocking = append(result.Blocking, Change{ diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index 5e5b0ba0..a74fca27 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -13,7 +13,11 @@ package interfacesnapshot -import "testing" +import ( + "os" + "regexp" + "testing" +) func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) { base := testSnapshot( @@ -310,6 +314,225 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing. } } +// registerReviewedFixture puts a fixture command in the reviewed table for the +// duration of one test, so the behaviour tests exercise the real lookup instead +// of depending on whichever production entries happen to exist. +func registerReviewedFixture(t *testing.T, change flagTypeChange) { + t.Helper() + if _, exists := reviewedFlagTypeChanges[change]; exists { + t.Fatalf("fixture %+v collides with a production entry", change) + } + reviewedFlagTypeChanges[change] = struct{}{} + t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) }) +} + +func TestCrossPlatformCoverageCompareAcceptsReviewedFlagTypeChange(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "int", false)), + ) + + // Without an entry the migration is still a blocking change. + if comparison := Compare(current, base, "base"); !hasChangeKind(comparison.Blocking, "flag_type_changed") { + t.Fatalf("an unreviewed type change should block: %#v", comparison.Blocking) + } + + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}) + if comparison := Compare(current, base, "base"); !comparison.Compatible { + t.Fatalf("a reviewed string->int migration should pass: %#v", comparison.Blocking) + } +} + +func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}) + + // The reverse migration shares command and flag but not direction, so the + // string->int entry must not admit it. + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "int", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + if comparison := Compare(current, base, "base"); comparison.Compatible { + t.Fatal("int->string must not be admitted by a string->int entry") + } +} + +func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search", testFlag("query", "int", false)), + ) + + // Same flag and direction, different command. + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws status", Flag: "query", From: "string", To: "int"}) + if comparison := Compare(current, base, "base"); comparison.Compatible { + t.Fatal("an entry for another command must not admit this migration") + } + + // Same command and direction, different flag. + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "elsewhere", From: "string", To: "int"}) + if comparison := Compare(current, base, "base"); comparison.Compatible { + t.Fatal("an entry for another flag must not admit this migration") + } +} + +// A reviewed migration is only accepted when the rest of the flag's contract +// held still. Each case bundles one unrelated regression with the reviewed type +// change and requires the type failure to reappear, which also anchors +// flagContractOtherwiseChanged against the checks it mirrors: a condition that +// drifts out of sync fails here instead of silently widening the exemption. +func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}) + + tests := []struct { + name string + base Flag + next Flag + kind string + }{ + { + name: "became required", + base: Flag{Name: "query", Type: "string"}, + next: Flag{Name: "query", Type: "int", Required: true}, + kind: "flag_became_required", + }, + { + name: "lost shorthand", + base: Flag{Name: "query", Type: "string", Shorthand: "q"}, + next: Flag{Name: "query", Type: "int"}, + kind: "flag_shorthand_changed", + }, + { + name: "changed no-opt", + base: Flag{Name: "query", Type: "string", NoOpt: "all"}, + next: Flag{Name: "query", Type: "int", NoOpt: "1"}, + kind: "flag_no_opt_changed", + }, + { + name: "became hidden", + base: Flag{Name: "query", Type: "string"}, + next: Flag{Name: "query", Type: "int", Hidden: true}, + kind: "flag_became_hidden", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + base := testSnapshot(testCommand("dws"), testCommand("dws search", test.base)) + current := testSnapshot(testCommand("dws"), testCommand("dws search", test.next)) + comparison := Compare(current, base, "base") + if !hasChangeKind(comparison.Blocking, test.kind) { + t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind) + } + if !hasChangeKind(comparison.Blocking, "flag_type_changed") { + t.Fatalf("a bundled regression must re-report the type change: %#v", comparison.Blocking) + } + }) + } +} + +// The exemption is resolved against the canonical Command.Path, never the +// alias-expanded accepted path. An aliased command enters compareEffectiveFlags +// once per accepted spelling, so keying on the accepted path would let every +// alias spelling re-report a reviewed migration. +func TestCrossPlatformCoverageCompareResolvesReviewedFlagTypeChangeByCanonicalPath(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)), + ) + current := testSnapshot( + testCommand("dws"), + testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "int", false)), + ) + + // The canonical path admits the migration for every accepted spelling. + canonical := flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"} + registerReviewedFixture(t, canonical) + if comparison := Compare(current, base, "base"); !comparison.Compatible { + t.Fatalf("canonical path should admit the migration on every alias: %#v", comparison.Blocking) + } + delete(reviewedFlagTypeChanges, canonical) + + // An entry spelled with the alias path matches nothing, because the lookup + // never sees the accepted spelling. + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws find", Flag: "query", From: "string", To: "int"}) + if comparison := Compare(current, base, "base"); comparison.Compatible { + t.Fatal("an alias-spelled entry must not admit the migration") + } +} + +// A string -> int migration always changes the recorded default ("" to "0"). +// Defaults are not part of what this gate enforces, and folding them into +// flagContractOtherwiseChanged would make every table entry dead on arrival. +func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeToleratesDefaultDrift(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}) + + base := testSnapshot( + testCommand("dws"), + testCommand("dws search", Flag{Name: "query", Type: "string", Default: ""}), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws search", Flag{Name: "query", Type: "int", Default: "0"}), + ) + if comparison := Compare(current, base, "base"); !comparison.Compatible { + t.Fatalf("default drift must not defeat a reviewed migration: %#v", comparison.Blocking) + } +} + +// reviewedEntryPattern matches one table entry as gofmt writes it on a single +// line. It is used to read the sibling copy of the table out of the policy +// helper, which cannot be imported: that directory is copied into a worktree at +// a historical revision and built there, so it must not depend on this package. +var reviewedEntryPattern = regexp.MustCompile( + `\{CommandPath:\s*"([^"]*)",\s*Flag:\s*"([^"]*)",\s*From:\s*"([^"]*)",\s*To:\s*"([^"]*)"\}`) + +const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go" + +// The two copies of the table must stay identical, otherwise a migration passes +// one gate and is blocked by the other in the same CI job. +func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) { + source, err := os.ReadFile(interfaceBaselineReviewedPath) + if err != nil { + t.Fatalf("读取 %s 失败: %v", interfaceBaselineReviewedPath, err) + } + + sibling := make(map[flagTypeChange]struct{}) + for _, match := range reviewedEntryPattern.FindAllStringSubmatch(string(source), -1) { + sibling[flagTypeChange{CommandPath: match[1], Flag: match[2], From: match[3], To: match[4]}] = struct{}{} + } + + // Without this the whole guard degrades into comparing two empty sets the + // moment the pattern stops matching, which is exactly how a duplicated + // allowlist goes stale unnoticed. + if len(sibling) == 0 { + t.Fatalf("未能从 %s 提取到任何条目:正则或表的书写形态已漂移,这条守卫已失效", interfaceBaselineReviewedPath) + } + + for change := range reviewedFlagTypeChanges { + if _, ok := sibling[change]; !ok { + t.Errorf("%+v 只存在于 interfacesnapshot,缺在 %s", change, interfaceBaselineReviewedPath) + } + } + for change := range sibling { + if _, ok := reviewedFlagTypeChanges[change]; !ok { + t.Errorf("%+v 只存在于 %s,缺在 interfacesnapshot", change, interfaceBaselineReviewedPath) + } + } +} + func testSnapshot(commands ...Command) Snapshot { return Snapshot{ SchemaVersion: SchemaVersion, diff --git a/internal/interfacesnapshot/reviewed.go b/internal/interfacesnapshot/reviewed.go new file mode 100644 index 00000000..6fcc5049 --- /dev/null +++ b/internal/interfacesnapshot/reviewed.go @@ -0,0 +1,121 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package interfacesnapshot + +// flagTypeChange identifies one flag type migration exactly: which command, +// which flag, and in which direction. Nothing is matched by wildcard — an entry +// that differs in any of the four fields does not apply. +// +// CommandPath is the canonical Command.Path form, which always includes the root +// command name ("dws minutes permission apply"). It is deliberately *not* the +// alias-expanded accepted path: a command reachable through an alias enters +// compareEffectiveFlags once per accepted spelling, and keying on the accepted +// path would let every alias spelling bypass the exemption and re-report the +// change. +// +// This table is duplicated in scripts/policy/interface-baseline/reviewed.go and +// the two must stay identical; TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline +// fails if they drift. The duplication is forced rather than chosen: +// check-authoritative-interface-baselines.sh copies the whole +// scripts/policy/interface-baseline directory into a worktree checked out at a +// historical revision and builds it there, so that copy cannot import a package +// this branch adds. +type flagTypeChange struct { + CommandPath string + Flag string + From string + To string +} + +// reviewedFlagTypeChanges enumerates the individually reviewed flag type +// migrations this gate accepts. A flag type is part of the published CLI +// contract, and a type swap cannot be proven safe from the type names alone — +// whether old invocations still parse depends on the concrete value domain the +// command enforces. So a migration is accepted only when this exact command, +// flag, and old→new pair appear here. Adding an entry is a contract decision +// and belongs in review, not in a feature change. +// +// Entries are direction-sensitive by construction: "string" → "int" is a +// separate key from "int" → "string" and only the reviewed direction is +// accepted. The reverse is not a candidate for this table — widening an int +// flag back to string lets values the parser used to reject reach RunE, which +// is a genuine loosening of validation rather than a type migration. +// +// The table alone does not admit a change: reviewedFlagTypeChange is consulted +// only when nothing else about the flag moved. See compareEffectiveFlags. +var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{ + // "dws minutes permission apply --policy" moved from a String flag parsed + // with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the + // same [2,4] domain check in RunE. + // + // This is admissible because the historical set of *successful* invocations + // is a subset of the new one, not merely similar to it: + // + // - Old: the value had to parse as base-10 and land in [2,4]. That is + // exactly "2", "3", "4" plus sign/leading-zero spellings of them + // ("+3", "03", "003", ...). + // - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) — + // base 0 — and RunE still enforces [2,4]. Every base-10 spelling above + // resolves to the same number under base 0, so no historical success + // becomes a failure. + // - Base 0 additionally accepts "0x3" and friends, which the old parser + // rejected. That widens the accepted set; it cannot break a caller who + // was already succeeding. + // - Values outside [2,4] and non-numeric values still fail. Only the + // failure *message and timing* move, from RunE to flag parsing. + // + // The flag's default value changes from "" to "0" as an unavoidable + // consequence of the type. Neither gate compares defaults, and a default is + // not reachable by a caller here because RunE requires the flag to be + // explicitly set, so this is not a contract change. + {CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {}, +} + +// reviewedFlagTypeChange reports whether this exact command, flag and direction +// is a reviewed migration. Callers must additionally establish that the rest of +// the flag's contract is unchanged before skipping the failure. +func reviewedFlagTypeChange(commandPath, flag, from, to string) bool { + _, reviewed := reviewedFlagTypeChanges[flagTypeChange{ + CommandPath: commandPath, + Flag: flag, + From: from, + To: to, + }] + return reviewed +} + +// flagContractOtherwiseChanged reports whether anything this gate checks about a +// flag, other than its type, differs between the two revisions. A reviewed type +// migration is only accepted when this returns false, so the exemption cannot +// carry an unrelated regression in behind it. +// +// Only the fields compareEffectiveFlags actually enforces are compared. Default +// is deliberately excluded: a string → int migration always changes it ("" to +// "0"), so including it would make every entry in the table dead. Deprecated is +// excluded for the same reason it is not a blocking change on its own. +func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool { + if !oldFlag.Required && newFlag.Required { + return true + } + if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand { + return true + } + if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt { + return true + } + if !oldFlag.Hidden && newFlag.Hidden { + return true + } + return false +} diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 72328efd..5a1f7576 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -1439,8 +1439,8 @@ var MessagesSendCard = shortcut.Shortcut{ Service: "chat", Command: "+messages-send-card", Product: "im", - Description: "创建流式卡片,可在同一次调用中写入内容并结束", - Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。", + Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人", + Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -1454,30 +1454,34 @@ var MessagesSendCard = shortcut.Shortcut{ CLIPath: "chat +messages-send-card", PrimaryCLIPath: "chat +messages-send-card", }, - Description: "创建流式卡片,可在同一次调用中写入内容并结束", + Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", - Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.", + Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId and atTag.", }, Selection: contract.SelectionSpec{ - AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束", - UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"}, + AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人", + UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"}, AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"}, Examples: []string{ - "dws chat +messages-send-card --group --content \"任务已完成\"", + "dws chat +messages-send-card --group --at-open-dingtalk-ids --content \"任务已完成\"", "dws chat +messages-send-card --receiver ", }, }, Parameters: []contract.ParamDecl{ {Name: "receiver-open-dingtalk-id", Property: "receiverOpenDingTalkId"}, + {Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"}, + {Name: "at-all", Property: "atAll"}, }, }, Flags: []shortcut.Flag{ - {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"}, + {Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥);艾特参数仅支持群聊 --group"}, {Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"}, {Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"}, - {Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"}, + {Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "群聊创建卡片时 @ 的 openDingTalkId 列表;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"}, + {Name: "at-all", Type: shortcut.FlagBool, Desc: "群聊创建卡片时 @ 所有人;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"}, + {Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片正文;群聊 @ 时 Runtime 自动前置 create 返回的 atTag;省略时仅创建并返回 bizId"}, {Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"}, }, Constraints: []shortcut.Constraint{ @@ -1487,10 +1491,15 @@ var MessagesSendCard = shortcut.Shortcut{ Flags: []string{"flow-status"}, Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content", }, + { + Kind: shortcut.ConstraintCustom, + Flags: []string{"group", "at-open-dingtalk-ids", "at-all"}, + Description: "艾特参数仅支持群聊 --group", + }, }, Tips: []string{ `dws chat +messages-send-card --group `, - `dws chat +messages-send-card --group --content "任务已完成"`, + `dws chat +messages-send-card --group --at-open-dingtalk-ids --content "任务已完成"`, }, Validate: func(rt *shortcut.RuntimeContext) error { if status := rt.Int("flow-status"); !validCardFlowStatus(status) { @@ -1499,6 +1508,9 @@ var MessagesSendCard = shortcut.Shortcut{ if rt.Changed("flow-status") && rt.Str("content") == "" { return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义") } + if rt.Str("group") == "" && (len(uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))) > 0 || rt.Bool("at-all")) { + return fmt.Errorf("--at-open-dingtalk-ids 和 --at-all 仅支持群聊 --group") + } return nil }, Execute: func(rt *shortcut.RuntimeContext) error { @@ -1506,9 +1518,18 @@ var MessagesSendCard = shortcut.Shortcut{ receiver := rt.Str("receiver") receiverOpenID := rt.Str("receiver-open-dingtalk-id") params := map[string]any{} + mentionsRequested := false switch { case group != "": params["openConversationId"] = group + if atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(atOpenIDs) > 0 { + params["atOpenDingTalkIds"] = atOpenIDs + mentionsRequested = true + } + if rt.Bool("at-all") { + params["atAll"] = true + mentionsRequested = true + } case receiver != "": openID, err := resolveUserOpenDingTalkID(rt, receiver) if err != nil { @@ -1524,6 +1545,10 @@ var MessagesSendCard = shortcut.Shortcut{ } status := rt.Int("flow-status") if rt.DryRun() { + plannedContent := content + if mentionsRequested { + plannedContent = "" + content + } return rt.Output(map[string]any{ "contractVersion": currentCardWorkflowContract.Version, "dry_run": true, @@ -1540,7 +1565,7 @@ var MessagesSendCard = shortcut.Shortcut{ "tool": "update_streaming_card", "arguments": map[string]any{ "bizId": "", - "msgContent": content, + "msgContent": plannedContent, "flowStatus": status, }, }, @@ -1555,9 +1580,13 @@ var MessagesSendCard = shortcut.Shortcut{ if bizID == "" { return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应") } + atTag := findCardAtTag(created) + if mentionsRequested && atTag == "" { + return fmt.Errorf("卡片已创建(bizId=%s),但下层未返回 atTag,无法保证请求的 @ 生效;未执行自动更新", bizID) + } updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{ "bizId": bizID, - "msgContent": content, + "msgContent": atTag + content, "flowStatus": status, }) if err != nil { @@ -1575,12 +1604,18 @@ var MessagesSendCard = shortcut.Shortcut{ } func findCardBizID(value any) string { + return strings.TrimSpace(findCardResponseString(value, []string{"bizId", "bizID", "biz_id"})) +} + +func findCardAtTag(value any) string { + return findCardResponseString(value, []string{"atTag"}) +} + +func findCardResponseString(value any, directKeys []string) string { switch typed := value.(type) { case map[string]any: - directKeys := []string{"bizId", "bizID", "biz_id"} for _, key := range directKeys { if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" { - candidate = strings.TrimSpace(candidate) return candidate } } @@ -1595,7 +1630,7 @@ func findCardBizID(value any) string { } for _, key := range envelopeKeys { visited[key] = struct{}{} - if candidate := findCardBizID(typed[key]); candidate != "" { + if candidate := findCardResponseString(typed[key], directKeys); candidate != "" { return candidate } } @@ -1608,13 +1643,13 @@ func findCardBizID(value any) string { } sort.Strings(remainingKeys) for _, key := range remainingKeys { - if candidate := findCardBizID(typed[key]); candidate != "" { + if candidate := findCardResponseString(typed[key], directKeys); candidate != "" { return candidate } } case []any: for _, child := range typed { - if candidate := findCardBizID(child); candidate != "" { + if candidate := findCardResponseString(child, directKeys); candidate != "" { return candidate } } @@ -1623,7 +1658,7 @@ func findCardBizID(value any) string { if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { var nested any if json.Unmarshal([]byte(trimmed), &nested) == nil { - return findCardBizID(nested) + return findCardResponseString(nested, directKeys) } } } diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index d2c618b9..a118157a 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -609,7 +609,7 @@ func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T) func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { fake := &larkAlignmentCaller{responses: map[string]string{ - "im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`, + "im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1","atTag":"甲 乙 "}}}`, "im/update_streaming_card": `{"result":{"updated":true}}`, }} helpers.InitDeps(fake) @@ -619,6 +619,8 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { root.SetArgs([]string{ "chat", "+messages-send-card", "--group", "cid", + "--at-open-dingtalk-ids", "D-one,D-two,D-one", + "--at-all", "--content", "完成", "--flow-status", "3", "--yes", @@ -630,7 +632,20 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { fake.calls[1].tool != "update_streaming_card" { t.Fatalf("card calls = %#v", fake.calls) } - if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" || + if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) { + t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want) + } + if fake.calls[0].args["atAll"] != true { + t.Fatalf("card create atAll = %#v", fake.calls[0].args["atAll"]) + } + if _, exists := fake.calls[1].args["atOpenDingTalkIds"]; exists { + t.Fatalf("card update leaked atOpenDingTalkIds: %#v", fake.calls[1].args) + } + if _, exists := fake.calls[1].args["atAll"]; exists { + t.Fatalf("card update leaked atAll: %#v", fake.calls[1].args) + } + if fake.calls[1].args["bizId"] != "biz-1" || + fake.calls[1].args["msgContent"] != "甲 乙 完成" || fake.calls[1].args["flowStatus"] != 3 { t.Fatalf("card update args = %#v", fake.calls[1].args) } @@ -643,6 +658,49 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { } } +func TestCrossPlatformCoverageMessagesSendCardKeepsContentWithoutAtTag(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/create_and_send_card": `{"result":{"bizId":"biz-no-mention"}}`, + "im/update_streaming_card": `{"result":{"updated":true}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send-card", + "--group", "cid", + "--content", "正文", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 2 || fake.calls[1].args["msgContent"] != "正文" { + t.Fatalf("card calls = %#v", fake.calls) + } +} + +func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/create_and_send_card": `{"result":{"bizId":"biz-missing-at-tag"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + root.SetArgs([]string{ + "chat", "+messages-send-card", + "--group", "cid", + "--at-open-dingtalk-ids", "D-mentioned", + "--content", "正文", + "--yes", + }) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "biz-missing-at-tag") || !strings.Contains(err.Error(), "atTag") { + t.Fatalf("error = %v, want recoverable missing-atTag error containing bizId", err) + } + if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" { + t.Fatalf("card calls = %#v, want create only", fake.calls) + } +} + func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) @@ -748,6 +806,50 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test } }) + t.Run("dry run mentions only in create", func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{ + "chat", "+messages-send-card", + "--group", "cid", + "--at-open-dingtalk-ids", "D-mentioned", + "--at-all", + "--content", "处理中", + "--dry-run", + "--yes", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(fake.calls) != 0 { + t.Fatalf("card group dry-run calls = %#v", fake.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + actions, _ := payload["actions"].([]any) + create, _ := actions[0].(map[string]any) + createArguments, _ := create["arguments"].(map[string]any) + update, _ := actions[1].(map[string]any) + updateArguments, _ := update["arguments"].(map[string]any) + if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true { + t.Fatalf("card create dry-run mentions = %#v", createArguments) + } + if _, exists := updateArguments["atOpenDingTalkIds"]; exists { + t.Fatalf("card update dry-run leaked atOpenDingTalkIds: %#v", updateArguments) + } + if _, exists := updateArguments["atAll"]; exists { + t.Fatalf("card update dry-run leaked atAll: %#v", updateArguments) + } + if updateArguments["msgContent"] != "处理中" { + t.Fatalf("card update dry-run content = %#v", updateArguments["msgContent"]) + } + }) + t.Run("receiver resolution error", func(t *testing.T) { fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"} helpers.InitDeps(fake) @@ -815,6 +917,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test {"--group", "cid", "--flow-status", "2"}, {"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"}, {"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"}, + {"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"}, + {"--receiver-open-dingtalk-id", "D-direct", "--at-all"}, } { helpers.InitDeps(&larkAlignmentCaller{}) root := newPlatformCoverageRoot() @@ -858,6 +962,24 @@ func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) { } } +func TestCrossPlatformCoverageFindCardAtTagResponseShapes(t *testing.T) { + for _, tc := range []struct { + value any + want string + }{ + {map[string]any{"atTag": "甲 "}, "甲 "}, + {map[string]any{"result": map[string]any{"atTag": "乙 "}}, "乙 "}, + {`{"result":{"card":{"atTag":"丙 "}}}`, "丙 "}, + {map[string]any{"atTag": " "}, ""}, + {map[string]any{"atTag": 42}, ""}, + {map[string]any{"result": map[string]any{"created": true}}, ""}, + } { + if got := findCardAtTag(tc.value); got != tc.want { + t.Errorf("findCardAtTag(%#v) = %q, want %q", tc.value, got, tc.want) + } + } +} + func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) { resetResourceDownloadHooks(t) t.Chdir(t.TempDir()) diff --git a/scripts/policy/check-mono-multi-skill-content.sh b/scripts/policy/check-mono-multi-skill-content.sh index a560b38e..c5f2501a 100755 --- a/scripts/policy/check-mono-multi-skill-content.sh +++ b/scripts/policy/check-mono-multi-skill-content.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Mono↔multi skill content QA (G1–G4). See docs/skill-mono-multi-qa.md +# Mono↔multi skill content QA (G1–G5). See docs/skill-mono-multi-qa.md set -eu ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" diff --git a/scripts/policy/interface-baseline/contract.go b/scripts/policy/interface-baseline/contract.go index bee33435..a8b010aa 100644 --- a/scripts/policy/interface-baseline/contract.go +++ b/scripts/policy/interface-baseline/contract.go @@ -312,10 +312,13 @@ func checkCompatibility(root *cobra.Command, baseline interfaceContract) []strin continue } if actual.Value.Type() != expectedFlag.Type { - failures = append(failures, fmt.Sprintf( - "%q flag --%s changed type from %s to %s", - displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(), - )) + if !reviewedFlagTypeChange(displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type()) || + flagContractOtherwiseChanged(expectedFlag, actual, actualScope) { + failures = append(failures, fmt.Sprintf( + "%q flag --%s changed type from %s to %s", + displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(), + )) + } } if expectedFlag.Shorthand != "" && actual.Shorthand != expectedFlag.Shorthand { failures = append(failures, fmt.Sprintf( @@ -443,11 +446,20 @@ func mergeContracts(historical, current interfaceContract) (interfaceContract, [ continue } if oldFlag.Type != newFlag.Type { - failures = append(failures, fmt.Sprintf( - "%q flag --%s changed type from %s to %s", - displayPath(path), name, oldFlag.Type, newFlag.Type, - )) - continue + if !reviewedFlagTypeChange(displayPath(path), name, oldFlag.Type, newFlag.Type) || + mergedFlagContractOtherwiseChanged(oldFlag, newFlag) { + failures = append(failures, fmt.Sprintf( + "%q flag --%s changed type from %s to %s", + displayPath(path), name, oldFlag.Type, newFlag.Type, + )) + continue + } + // A reviewed migration keeps merging, and the merged entry keeps + // the historical type below, exactly as this function keeps the + // historical shorthand, required and hidden values. The baseline + // records what was promised, so a later --check still resolves + // this flag through the reviewed table rather than silently + // adopting the new type as history. } if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand { failures = append(failures, fmt.Sprintf( diff --git a/scripts/policy/interface-baseline/contract_test.go b/scripts/policy/interface-baseline/contract_test.go index 9911bd1a..c2838d5f 100644 --- a/scripts/policy/interface-baseline/contract_test.go +++ b/scripts/policy/interface-baseline/contract_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" "github.com/spf13/cobra" ) @@ -239,6 +240,286 @@ func assertFailureContains(t *testing.T, failures []string, want string) { t.Fatalf("failures %v do not contain %q", failures, want) } +// policyRoot builds "dws old" with a single --policy flag declared by declare, +// so a baseline and a candidate can differ only in how that flag is typed. +func policyRoot(declare func(*cobra.Command)) *cobra.Command { + root := &cobra.Command{Use: "dws"} + old := &cobra.Command{Use: "old", Run: func(*cobra.Command, []string) {}} + declare(old) + root.AddCommand(old) + root.InitDefaultHelpCmd() + return root +} + +// registerReviewedFixture puts a fixture command in the reviewed table for the +// duration of one test, so the behaviour tests exercise the real lookup instead +// of depending on whichever production entries happen to exist. +func registerReviewedFixture(t *testing.T, change flagTypeChange) { + t.Helper() + if _, exists := reviewedFlagTypeChanges[change]; exists { + t.Fatalf("fixture %+v collides with a production entry", change) + } + reviewedFlagTypeChanges[change] = struct{}{} + t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) }) +} + +const typeChangedFailure = "changed type from string to int" + +func TestReviewedFlagTypeChangeAcceptsRegisteredMigration(t *testing.T) { + baseline := snapshot(policyRoot(func(command *cobra.Command) { + command.Flags().String("policy", "", "policy") + })) + current := policyRoot(func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + }) + + // Without an entry the migration is still a blocking change. + assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure) + + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"}) + if failures := checkCompatibility(current, baseline); len(failures) != 0 { + t.Fatalf("a reviewed string->int migration should pass: %v", failures) + } +} + +func TestReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"}) + + // The reverse migration shares command and flag but not direction, so the + // string->int entry must not admit it. + baseline := snapshot(policyRoot(func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + })) + current := policyRoot(func(command *cobra.Command) { + command.Flags().String("policy", "", "policy") + }) + assertFailureContains(t, checkCompatibility(current, baseline), "changed type from int to string") +} + +func TestReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws other", Flag: "policy", From: "string", To: "int"}) + baseline := snapshot(policyRoot(func(command *cobra.Command) { + command.Flags().String("policy", "", "policy") + })) + current := policyRoot(func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + }) + // Same flag and direction, different command. + assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure) + + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "elsewhere", From: "string", To: "int"}) + // Same command and direction, different flag. + assertFailureContains(t, checkCompatibility(current, baseline), typeChangedFailure) +} + +// A reviewed migration is only accepted when the rest of the flag's contract +// held still. Each case bundles one unrelated regression with the reviewed type +// change and requires the type failure to reappear, which also anchors +// flagContractOtherwiseChanged against the checks it mirrors: a condition that +// drifts out of sync fails here instead of silently widening the exemption. +func TestReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"}) + + tests := []struct { + name string + baseline func(*cobra.Command) + current func(*cobra.Command) + want string + }{ + { + name: "became required", + baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + _ = command.MarkFlagRequired("policy") + }, + want: "became required", + }, + { + name: "became hidden", + baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + _ = command.Flags().MarkHidden("policy") + }, + want: "became hidden", + }, + { + name: "lost shorthand", + baseline: func(command *cobra.Command) { command.Flags().StringP("policy", "p", "", "policy") }, + current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") }, + want: "lost shorthand", + }, + { + name: "changed no-opt value", + baseline: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + command.Flags().Lookup("policy").NoOptDefVal = "4" + }, + want: "changed no-opt value", + }, + { + name: "narrowed persistent scope", + baseline: func(command *cobra.Command) { command.PersistentFlags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") }, + want: "narrowed persistent scope", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + failures := checkCompatibility(policyRoot(test.current), snapshot(policyRoot(test.baseline))) + assertFailureContains(t, failures, test.want) + assertFailureContains(t, failures, typeChangedFailure) + }) + } +} + +// mergeContracts runs the same admission decision on --merge. A reviewed +// migration keeps merging and the merged entry keeps the historical type, so a +// later --check still resolves the flag through the reviewed table. +func TestMergeAcceptsReviewedFlagTypeChangeAndKeepsHistoricalType(t *testing.T) { + historical := snapshot(policyRoot(func(command *cobra.Command) { + command.Flags().String("policy", "", "policy") + })) + current := snapshot(policyRoot(func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + })) + + if _, failures := mergeContracts(historical, current); len(failures) == 0 { + t.Fatal("an unreviewed merge should report the type change") + } + + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"}) + merged, failures := mergeContracts(historical, current) + if len(failures) != 0 { + t.Fatalf("a reviewed merge should pass: %v", failures) + } + if got := merged.Commands["old"].Flags["policy"].Type; got != "string" { + t.Fatalf("merged type = %q, want the historical %q", got, "string") + } +} + +// mergeContracts applies the same "nothing else moved" condition as +// checkCompatibility, but against two recorded contracts rather than a live +// pflag definition. Each case bundles one unrelated regression with the reviewed +// type change and requires the type failure to reappear, anchoring +// mergedFlagContractOtherwiseChanged against the checks it mirrors. +func TestMergeRejectsReviewedFlagTypeChangeWithBundledRegression(t *testing.T) { + registerReviewedFixture(t, flagTypeChange{CommandPath: "dws old", Flag: "policy", From: "string", To: "int"}) + + tests := []struct { + name string + historical func(*cobra.Command) + current func(*cobra.Command) + }{ + { + name: "lost shorthand", + historical: func(command *cobra.Command) { command.Flags().StringP("policy", "p", "", "policy") }, + current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") }, + }, + { + name: "became required", + historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + _ = command.MarkFlagRequired("policy") + }, + }, + { + name: "became hidden", + historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + _ = command.Flags().MarkHidden("policy") + }, + }, + { + name: "changed no-opt value", + historical: func(command *cobra.Command) { command.Flags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { + command.Flags().Int("policy", 0, "policy") + command.Flags().Lookup("policy").NoOptDefVal = "4" + }, + }, + { + name: "narrowed persistent scope", + historical: func(command *cobra.Command) { command.PersistentFlags().String("policy", "", "policy") }, + current: func(command *cobra.Command) { command.Flags().Int("policy", 0, "policy") }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, failures := mergeContracts( + snapshot(policyRoot(test.historical)), + snapshot(policyRoot(test.current)), + ) + assertFailureContains(t, failures, typeChangedFailure) + }) + } +} + +// The behaviour tests above register their own fixtures, so they cannot catch a +// production entry whose key is spelled in the wrong form — that mistake +// disables the exemption silently and only a real gate run reports it. Recompute +// every key through displayPath instead of trusting the spelling in the table. +func TestReviewedFlagTypeChangeKeysUseDisplayPaths(t *testing.T) { + if len(reviewedFlagTypeChanges) == 0 { + t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除") + } + for change := range reviewedFlagTypeChanges { + if got := displayPath(internalCommandPath(change.CommandPath)); got != change.CommandPath { + t.Errorf("命令路径不是 displayPath 的规范形态\n 登记: %s\n 规范: %s", change.CommandPath, got) + } + if change.Flag == "" || strings.HasPrefix(change.Flag, "-") { + t.Errorf("%s: flag 名应是裸名(lookupFlag 用裸名查找),登记为 %q", change.CommandPath, change.Flag) + } + if change.From == "" || change.To == "" || change.From == change.To { + t.Errorf("%s --%s: %q -> %q 不构成类型迁移", change.CommandPath, change.Flag, change.From, change.To) + } + } +} + +// Anchor every production entry to the real command tree. A misspelled command +// or flag name would leave the exemption permanently inert, and the behaviour +// tests cannot see that because they register their own paths. +func TestReviewedFlagTypeChangeEntriesResolveInTheRealCommandTree(t *testing.T) { + if len(reviewedFlagTypeChanges) == 0 { + t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除") + } + root := app.NewRootCommand() + for change := range reviewedFlagTypeChanges { + command, resolved := resolveCommand(root, internalCommandPath(change.CommandPath)) + if !resolved { + t.Errorf("%q 在真实命令树中不存在", change.CommandPath) + continue + } + flag, _ := lookupFlag(command, change.Flag) + if flag == nil { + t.Errorf("%q 上不存在 --%s", change.CommandPath, change.Flag) + continue + } + // Exactly one side holds depending on whether the migration has landed: + // From before it does, To afterwards. Asserting membership catches a + // misspelled type name without failing while the migration is pending. + if actual := flag.Value.Type(); actual != change.From && actual != change.To { + t.Errorf("%q --%s 当前类型 %q 既不是 %q 也不是 %q", + change.CommandPath, change.Flag, actual, change.From, change.To) + } + } +} + +// internalCommandPath inverts displayPath so a table key can be fed back into +// resolveCommand and displayPath. +func internalCommandPath(displayed string) string { + if displayed == "dws" { + return "root" + } + return strings.ReplaceAll(strings.TrimPrefix(displayed, "dws "), " ", ".") +} + func testRoot() *cobra.Command { root := &cobra.Command{Use: "dws"} old := &cobra.Command{Use: "old"} diff --git a/scripts/policy/interface-baseline/reviewed.go b/scripts/policy/interface-baseline/reviewed.go new file mode 100644 index 00000000..cb05c225 --- /dev/null +++ b/scripts/policy/interface-baseline/reviewed.go @@ -0,0 +1,140 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import "github.com/spf13/pflag" + +// flagTypeChange identifies one flag type migration exactly: which command, +// which flag, and in which direction. Nothing is matched by wildcard — an entry +// that differs in any of the four fields does not apply. +// +// CommandPath is the display form produced by displayPath ("dws minutes +// permission apply"), not the dot-separated internal path. Writing the internal +// form here would silently disable the exemption instead of failing loudly, so +// TestReviewedFlagTypeChangeKeysUseDisplayPaths recomputes every key through +// displayPath rather than trusting the spelling below. +// +// This table is duplicated in internal/interfacesnapshot/reviewed.go and the two +// must stay identical; the interfacesnapshot package has a test that fails if +// they drift. The duplication is forced rather than chosen: +// check-authoritative-interface-baselines.sh copies this whole directory into a +// worktree checked out at a historical revision and builds it there, so nothing +// here may import a package this branch adds — that build would fail with an +// unresolved import instead of reporting a compatibility result. +type flagTypeChange struct { + CommandPath string + Flag string + From string + To string +} + +// reviewedFlagTypeChanges enumerates the individually reviewed flag type +// migrations this gate accepts. A flag type is part of the published CLI +// contract, and a type swap cannot be proven safe from the type names alone — +// whether old invocations still parse depends on the concrete value domain the +// command enforces. So a migration is accepted only when this exact command, +// flag, and old→new pair appear here. Adding an entry is a contract decision +// and belongs in review, not in a feature change. +// +// Entries are direction-sensitive by construction: "string" → "int" is a +// separate key from "int" → "string" and only the reviewed direction is +// accepted. The reverse is not a candidate for this table — widening an int +// flag back to string lets values the parser used to reject reach RunE, which +// is a genuine loosening of validation rather than a type migration. +// +// The table alone does not admit a change: reviewedFlagTypeChange is consulted +// only when nothing else about the flag moved. See the callers in +// checkCompatibility and mergeContracts. +var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{ + // "dws minutes permission apply --policy" moved from a String flag parsed + // with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the + // same [2,4] domain check in RunE. + // + // This is admissible because the historical set of *successful* invocations + // is a subset of the new one, not merely similar to it: + // + // - Old: the value had to parse as base-10 and land in [2,4]. That is + // exactly "2", "3", "4" plus sign/leading-zero spellings of them + // ("+3", "03", "003", ...). + // - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) — + // base 0 — and RunE still enforces [2,4]. Every base-10 spelling above + // resolves to the same number under base 0, so no historical success + // becomes a failure. + // - Base 0 additionally accepts "0x3" and friends, which the old parser + // rejected. That widens the accepted set; it cannot break a caller who + // was already succeeding. + // - Values outside [2,4] and non-numeric values still fail. Only the + // failure *message and timing* move, from RunE to flag parsing. + // + // The flag's default value changes from "" to "0" as an unavoidable + // consequence of the type. Neither gate compares defaults, and a default is + // not reachable by a caller here because RunE requires the flag to be + // explicitly set, so this is not a contract change. + {CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {}, +} + +// reviewedFlagTypeChange reports whether this exact command, flag and direction +// is a reviewed migration. Callers must additionally establish that the rest of +// the flag's contract is unchanged before skipping the failure. +func reviewedFlagTypeChange(commandPath, flag, from, to string) bool { + _, reviewed := reviewedFlagTypeChanges[flagTypeChange{ + CommandPath: commandPath, + Flag: flag, + From: from, + To: to, + }] + return reviewed +} + +// flagContractOtherwiseChanged reports whether anything checkCompatibility +// enforces about a flag, other than its type, regressed against the baseline. A +// reviewed type migration is only accepted when this returns false, so the +// exemption cannot carry an unrelated regression in behind it. +// +// Every condition here mirrors one of the checks that follow the type check in +// checkCompatibility and must stay in step with them. The pairing is anchored by +// tests that bundle each regression with the reviewed type change and require +// the type failure to reappear, so a condition that drifts out of sync fails +// loudly instead of silently widening the exemption. +func flagContractOtherwiseChanged(expected flagContract, actual *pflag.Flag, actualScope string) bool { + if expected.Shorthand != "" && actual.Shorthand != expected.Shorthand { + return true + } + if expected.RequiredSet && !expected.Required && isRequiredFlag(actual) { + return true + } + if expected.HiddenSet && !expected.Hidden && actual.Hidden { + return true + } + if expected.NoOptSet && actual.NoOptDefVal != expected.NoOpt { + return true + } + if expected.ScopeSet && expected.Scope == "persistent" && actualScope == "local" { + return true + } + return false +} + +// mergedFlagContractOtherwiseChanged is the mergeContracts counterpart of +// flagContractOtherwiseChanged, comparing two recorded contracts rather than a +// contract against a live pflag definition. It mirrors the checks that follow +// the type check in mergeContracts. +func mergedFlagContractOtherwiseChanged(oldFlag, newFlag flagContract) bool { + if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand { + return true + } + if oldFlag.RequiredSet && !oldFlag.Required && newFlag.Required { + return true + } + if oldFlag.HiddenSet && !oldFlag.Hidden && newFlag.Hidden { + return true + } + if oldFlag.NoOptSet && newFlag.NoOptSet && oldFlag.NoOpt != newFlag.NoOpt { + return true + } + if oldFlag.ScopeSet && oldFlag.Scope == "persistent" && newFlag.Scope == "local" { + return true + } + return false +} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 84447246..d186a6c1 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -18,6 +18,18 @@ import ( const schemaContractVersion = 3 +// propertySourceReviewedMappingExclusion is the provenance source a parameter +// reports when its property is deliberately omitted through the reviewed +// mapping exclusion table (internal/cli/schema_parameter_mapping_ledger.go). +// Only this source qualifies for the property-clearing carve-out in +// checkParameterCompatibility. +const propertySourceReviewedMappingExclusion = "reviewed_mapping_exclusion" + +// interfaceModeMCP is the interface_mode of a tool backed by exactly one RPC. +// Only a tool that is mcp on both sides can qualify for the interface_ref +// redirect carve-out in compatibleInterfaceRefRedirect. +const interfaceModeMCP = "mcp" + type schemaContract struct { Version int `json:"version"` Products map[string]productSchema `json:"products"` @@ -53,6 +65,7 @@ type positionalSchema struct { type parameterSchema struct { Type string `json:"type"` Property string `json:"property,omitempty"` + PropertySource string `json:"property_source,omitempty"` InterfaceType string `json:"interface_type,omitempty"` Required bool `json:"required,omitempty"` CLIRequired bool `json:"cli_required,omitempty"` @@ -361,6 +374,11 @@ func normalizeParameter(raw json.RawMessage) (parameterSchema, error) { InterfaceDefault json.RawMessage `json:"interface_default"` Format string `json:"format"` Enum []string `json:"enum"` + FieldProvenance struct { + Property struct { + Source string `json:"source"` + } `json:"property"` + } `json:"field_provenance"` } if err := json.Unmarshal(raw, ¶meter); err != nil { return parameterSchema{}, err @@ -388,6 +406,7 @@ func normalizeParameter(raw json.RawMessage) (parameterSchema, error) { return parameterSchema{ Type: parameterType, Property: strings.TrimSpace(parameter.Property), + PropertySource: strings.TrimSpace(parameter.FieldProvenance.Property.Source), InterfaceType: strings.TrimSpace(parameter.InterfaceType), Required: parameter.Required, CLIRequired: parameter.CLIRequired, @@ -479,7 +498,6 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri }{ {name: "primary_cli_path", old: oldTool.PrimaryCLIPath, new: newTool.PrimaryCLIPath}, {name: "interface_mode", old: oldTool.InterfaceMode, new: newTool.InterfaceMode}, - {name: "interface_ref", old: oldTool.InterfaceRef, new: newTool.InterfaceRef}, {name: "availability", old: oldTool.Availability, new: newTool.Availability}, {name: "effect", old: oldTool.Effect, new: newTool.Effect}, {name: "risk", old: oldTool.Risk, new: newTool.Risk}, @@ -510,10 +528,84 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri } failures = append(failures, checkParameterCompatibility(toolPath, parameter, oldParameter, newParameter)...) } + + // interface_ref is evaluated last, because the redirect carve-out below is + // conditional on every other check for this tool having passed. + if oldTool.InterfaceRef != newTool.InterfaceRef && + !compatibleInterfaceRefRedirect(toolPath, oldTool, newTool, failures) { + failures = append(failures, fmt.Sprintf("schema tool %q changed interface_ref", toolPath)) + } sort.Strings(failures) return failures } +// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed +// backend RPC migrations this gate accepts. Schema shape alone cannot prove two +// RPCs share business semantics, permissions, error behaviour, or side effects, +// so a redirect is only accepted when the specific tool and the specific +// old→new pair appear here. Any other ref change is still reported. +// +// Keyed by tool path ("/"), then by the previous +// interface_ref, with the value being the single accepted new ref. Both refs are +// the canonicalized interface_ref JSON exactly as parseTool produces it (see +// canonicalRawJSON) — not a bare RPC name. Adding an entry is a contract +// decision and belongs in review, not in a feature change. +var reviewedInterfaceRefRedirect = map[string]map[string]string{ + // The style surface moved from update_range (which writes values) to + // set_cell_range's cellStyles payload (style-only, preserving values). This + // is the only channel that can express italic / underline / strike-through / + // font family / borders. Same product, same target range semantics, same + // permission scope; the flat style properties it loses are accepted + // separately as reviewed mapping exclusions. + "sheet/sheet.range_set_style": { + `{"product_id":"sheet","rpc_name":"update_range"}`: `{"product_id":"sheet","rpc_name":"set_cell_range"}`, + }, +} + +// compatibleInterfaceRefRedirect accepts repointing a tool at a different +// backing RPC when the migration is an explicitly reviewed entry in +// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably +// unchanged. +// +// interface_ref is audit and traceability metadata: it records which RPC backs a +// leaf. Nothing reads it at runtime — the tool a leaf invokes is decided in the +// CLI source, so a stale ref does not misroute a call, it only misinforms a +// reader. When the backing RPC genuinely moves, the honest options are to update +// the ref or to keep publishing a name that no longer matches the request being +// sent. +// +// Being audit-only is why a reviewed redirect can be accepted at all; it is not +// a reason to accept redirects in general. Two RPCs with compatible Schema +// parameters may still differ in permissions, quota, error taxonomy, or side +// effects, none of which this gate can see. Hence the allowlist below, plus: +// +// - interface_mode is unchanged and stays "mcp". A move to or from +// "composite" is a change in kind, not a redirect, and is still reported. +// - both refs are non-empty. Removing a ref is not a redirect. +// - no other compatibility failure was recorded for this tool. This is the +// operative meaning of "the CLI contract is unchanged": no parameter was +// lost, none became required, no type / default / format / enum moved, no +// constraint tightened, no positional or dry_run change. Any one of those +// re-reports the redirect, so the exemption cannot smuggle a surface change +// in behind a backend move. +// +// A cleared property that resolved through a reviewed mapping exclusion is +// already accepted by checkParameterCompatibility and so does not block this; +// that pairing is expected, since a leaf moving to a nested payload loses its +// flat property names in the same change. +func compatibleInterfaceRefRedirect(toolPath string, oldTool, newTool toolSchema, otherFailures []string) bool { + if oldTool.InterfaceMode != newTool.InterfaceMode || newTool.InterfaceMode != interfaceModeMCP { + return false + } + if oldTool.InterfaceRef == "" || newTool.InterfaceRef == "" { + return false + } + if reviewedInterfaceRefRedirect[toolPath][oldTool.InterfaceRef] != newTool.InterfaceRef { + return false + } + return len(otherFailures) == 0 +} + // compatibleAdditiveConstraintEvolution accepts constraint evolution that // cannot invalidate an invocation expressible by the historical public // parameter contract. Existing groups may only gain members; additions to a @@ -730,7 +822,6 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet new string }{ {name: "type", old: oldParameter.Type, new: newParameter.Type}, - {name: "property", old: oldParameter.Property, new: newParameter.Property}, {name: "default", old: oldParameter.Default, new: newParameter.Default}, {name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault}, {name: "format", old: oldParameter.Format, new: newParameter.Format}, @@ -739,6 +830,36 @@ func checkParameterCompatibility(toolPath, name string, oldParameter, newParamet failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name)) } } + // Clearing property is accepted as compatible only when the new value is + // omitted through a reviewed mapping exclusion. This is the same shape as + // the interface_type carve-out below: a declaration that the flag has no + // single top-level RPC property, replacing a value that is no longer true. + // + // It exists because a leaf whose backing RPC gains a nested payload has no + // honest flat property to publish. The alternative outcomes are both worse: + // keep naming a field the request no longer contains, or let assembly fall + // back to flag_name_inference and publish a name that appears in no request + // at all. + // + // The predicate is deliberately narrow: + // - old non-empty AND new empty (a redirect to a different non-empty + // value stays a contract break) + // - the new value resolved through reviewed_mapping_exclusion, so an + // accidental or silent drop is still reported + // + // The exclusion table cannot be abused to wave through arbitrary clearing: + // internal/cli/schema_parameter_bindings.go verifies that every parameter + // claiming an exclusion really does deliver an empty property, and every + // entry carries a non-empty reviewed reason. + // + // Consumers must treat a missing property as "no direct mapping" and read + // the provenance reason for where the value actually lands. Re-populating a + // property requires an explicit ParamDecl declaration. + if oldParameter.Property != newParameter.Property && + !(oldParameter.Property != "" && newParameter.Property == "" && + newParameter.PropertySource == propertySourceReviewedMappingExclusion) { + failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed property", toolPath, name)) + } // Clearing interface_type is accepted as compatible: a deliberate, // wire-visible policy decision taken with the pinned MCP metadata // retirement. Production no longer projects MCP-sourced types unless diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index 4cdbfc18..2d10fa53 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -5,6 +5,7 @@ package main import ( "bytes" + "encoding/json" "errors" "os" "path/filepath" @@ -340,6 +341,18 @@ func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) { {name: "changed property", want: "changed property", mutate: func(contract *schemaContract) { mutateParameter(contract, func(parameter *parameterSchema) { parameter.Property = "subject" }) }}, + {name: "cleared property without a reviewed exclusion", want: "changed property", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { + parameter.Property = "" + parameter.PropertySource = "flag_name_inference" + }) + }}, + {name: "redirected property despite a reviewed exclusion", want: "changed property", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { + parameter.Property = "subject" + parameter.PropertySource = propertySourceReviewedMappingExclusion + }) + }}, {name: "changed interface type", want: "changed interface_type", mutate: func(contract *schemaContract) { mutateParameter(contract, func(parameter *parameterSchema) { parameter.InterfaceType = "integer" }) }}, @@ -679,3 +692,242 @@ func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing t.Fatal("each historical group must match a distinct expanded group") } } + +// Clearing a property through the reviewed mapping exclusion table is the one +// accepted shape, mirroring the interface_type retirement allowance. A leaf +// whose backing RPC moved to a nested payload has no honest flat property to +// publish; the alternatives are naming a field the request no longer contains, +// or letting flag_name_inference publish a name that appears in no request. +func TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion(t *testing.T) { + baseline := baselineContract() + + // Accepted: non-empty -> empty, resolved through a reviewed exclusion. + current := cloneContract(baseline) + mutateParameter(¤t, func(parameter *parameterSchema) { + parameter.Property = "" + parameter.PropertySource = propertySourceReviewedMappingExclusion + }) + if failures := checkCompatibility(baseline, current); len(failures) != 0 { + t.Fatalf("clearing property through a reviewed exclusion should pass: %v", failures) + } + + // Every neighbouring shape must stay incompatible, so the carve-out cannot + // be widened by accident. + for _, tc := range []struct { + name string + property string + source string + }{ + {"cleared by inference", "", "flag_name_inference"}, + {"cleared by a native annotation", "", "native_annotation"}, + {"cleared with no recorded source", "", ""}, + {"redirected to another non-empty value", "cellStyles", propertySourceReviewedMappingExclusion}, + } { + t.Run(tc.name, func(t *testing.T) { + drifted := cloneContract(baseline) + mutateParameter(&drifted, func(parameter *parameterSchema) { + parameter.Property = tc.property + parameter.PropertySource = tc.source + }) + failures := checkCompatibility(baseline, drifted) + if len(failures) == 0 { + t.Fatal("must remain incompatible") + } + var sawProperty bool + for _, failure := range failures { + if strings.Contains(failure, "changed property") { + sawProperty = true + } + } + if !sawProperty { + t.Fatalf("expected a changed property failure, got %v", failures) + } + }) + } + + // A parameter that never published a property must not gain one silently + // just because it carries an exclusion source. + populated := cloneContract(baseline) + mutateParameter(&populated, func(parameter *parameterSchema) { parameter.Property = "" }) + gained := cloneContract(populated) + mutateParameter(&gained, func(parameter *parameterSchema) { + parameter.Property = "backgroundColors" + parameter.PropertySource = propertySourceReviewedMappingExclusion + }) + if failures := checkCompatibility(populated, gained); len(failures) == 0 { + t.Fatal("populating a previously empty property must stay incompatible") + } +} + +// Repointing a leaf at a different backing RPC is accepted only when the +// CLI-facing contract is provably unchanged. interface_ref is audit metadata; +// nothing reads it at runtime, so a stale value misinforms a reader rather than +// misrouting a call. The gate is that no other compatibility check for the tool +// failed — that is the operative meaning of "the contract is unchanged". +// reviewedInterfaceRefRedirect 的键值必须是 parseTool 经 canonicalRawJSON 实际产出 +// 的紧凑 JSON。这条守卫是必要的:该表先前误登记为裸 RPC 名("update_range"),后又 +// 误登记为带空格的美化 JSON,两次都让豁免完全失效——而上面的用例是用测试自己注册 +// 的值断言的,所以两次都通过了,只有真实门禁才报错。这里用 canonicalRawJSON 复算 +// 期望值,把格式锚定到生产代码而不是作者的书写习惯。 +func TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON(t *testing.T) { + canon := func(raw string) string { + got, err := canonicalRawJSON(json.RawMessage(raw)) + if err != nil { + t.Fatalf("canonicalRawJSON(%s): %v", raw, err) + } + return got + } + if len(reviewedInterfaceRefRedirect) == 0 { + t.Fatal("redirect allowlist 为空:若确已清空,请同时删除这条守卫") + } + for toolPath, pairs := range reviewedInterfaceRefRedirect { + if len(pairs) == 0 { + t.Errorf("%s: 空的 redirect 表项没有意义", toolPath) + } + for oldRef, newRef := range pairs { + if got := canon(oldRef); got != oldRef { + t.Errorf("%s: old ref 不是规范形态\n 登记: %s\n 规范: %s", toolPath, oldRef, got) + } + if got := canon(newRef); got != newRef { + t.Errorf("%s: new ref 不是规范形态\n 登记: %s\n 规范: %s", toolPath, newRef, got) + } + if oldRef == newRef { + t.Errorf("%s: old 与 new 相同,不构成 redirect", toolPath) + } + } + } +} + +func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) { + // The redirect carve-out only accepts an explicitly reviewed tool + old→new + // ref pair. Register the fixture's path for the duration of this test so the + // accepted shapes below exercise the allowlist rather than a blanket rule. + const fixturePath = "doc/doc.create" + oldRef := `{"product_id":"sheet","rpc_name":"update_range"}` + newRef := `{"product_id":"sheet","rpc_name":"set_cell_range"}` + reviewedInterfaceRefRedirect[fixturePath] = map[string]string{oldRef: newRef} + t.Cleanup(func() { delete(reviewedInterfaceRefRedirect, fixturePath) }) + + // The baseline fixture is interface_mode=local; the carve-out only applies to + // mcp-backed leaves, so establish an mcp baseline first. + baseline := cloneContract(baselineContract()) + mutateTool(&baseline, func(tool *toolSchema) { + tool.InterfaceMode = interfaceModeMCP + tool.InterfaceRef = oldRef + }) + + // Accepted: mcp -> mcp, both refs non-empty, the pair is reviewed, nothing + // else changed. + redirected := cloneContract(baseline) + mutateTool(&redirected, func(tool *toolSchema) { tool.InterfaceRef = newRef }) + if failures := checkCompatibility(baseline, redirected); len(failures) != 0 { + t.Fatalf("a reviewed interface_ref redirect should pass: %v", failures) + } + + // Paired with a reviewed property clearing, which is the realistic shape: + // a leaf moving to a nested payload loses its flat property names. + withCleared := cloneContract(redirected) + mutateParameter(&withCleared, func(parameter *parameterSchema) { + parameter.Property = "" + parameter.PropertySource = propertySourceReviewedMappingExclusion + }) + if failures := checkCompatibility(baseline, withCleared); len(failures) != 0 { + t.Fatalf("redirect plus reviewed property clearing should pass: %v", failures) + } + + // Rejected shapes. Each must still report the redirect, so a surface change + // cannot ride along behind a backend move. + for _, tc := range []struct { + name string + mutate func(*schemaContract) + }{ + {"ref removed rather than redirected", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = "" }) + }}, + {"mode moved to composite", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { + tool.InterfaceMode = "composite" + tool.InterfaceRef = "" + }) + }}, + {"mode moved away from mcp while the ref is redirected", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { + tool.InterfaceMode = "local" + tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` + }) + }}, + {"a parameter became required", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` }) + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Required = true }) + }}, + {"a parameter type moved", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` }) + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Type = "integer" }) + }}, + {"a property was cleared without a reviewed exclusion", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` }) + mutateParameter(contract, func(parameter *parameterSchema) { + parameter.Property = "" + parameter.PropertySource = "flag_name_inference" + }) + }}, + {"a confirmation gate moved", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { + tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` + tool.Confirmation = "user_required" + }) + }}, + {"a parameter was dropped", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { + tool.InterfaceRef = newRef + delete(tool.Parameters, "title") + }) + }}, + // The allowlist itself must be load-bearing: a redirect that is not an + // exact reviewed tool + old→new pair stays incompatible even when the rest + // of the contract is untouched. Schema shape cannot prove two RPCs share + // permissions, error taxonomy, or side effects. + {"redirect to a ref that is not the reviewed target", func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { + tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"some_other_rpc"}` + }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + drifted := cloneContract(baseline) + tc.mutate(&drifted) + failures := checkCompatibility(baseline, drifted) + var sawRef bool + for _, failure := range failures { + if strings.Contains(failure, "changed interface_ref") { + sawRef = true + } + } + if !sawRef { + t.Fatalf("expected the redirect to stay reported, got %v", failures) + } + }) + } + + // The allowlist must be load-bearing: the exact same old→new pair on a tool + // with no reviewed entry stays incompatible. Asserted outside the table + // because the registration has to survive until checkCompatibility runs. + t.Run("redirect on a tool absent from the reviewed allowlist", func(t *testing.T) { + saved := reviewedInterfaceRefRedirect[fixturePath] + delete(reviewedInterfaceRefRedirect, fixturePath) + defer func() { reviewedInterfaceRefRedirect[fixturePath] = saved }() + + unlisted := cloneContract(baseline) + mutateTool(&unlisted, func(tool *toolSchema) { tool.InterfaceRef = newRef }) + failures := checkCompatibility(baseline, unlisted) + var sawRef bool + for _, failure := range failures { + if strings.Contains(failure, "changed interface_ref") { + sawRef = true + } + } + if !sawRef { + t.Fatalf("an unlisted tool must not get the redirect carve-out, got %v", failures) + } + }) +} diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index c7a6da11..96b8b55c 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -142,12 +142,27 @@ omit_global: disposition: covered_by reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名" -# Byte-identical pairs (content surfaces that must stay in sync). +# Reviewed file pairs. Most are byte-identical; layout-only link differences +# must be declared as data-driven substitutions. paired_files: + - mono: references/products/sheet.md + multi: dingtalk-misc/references/sheet.md + mode: link-normalized + link_substitutions: + - mono: "../url-patterns.md" + multi: "../../dingtalk-shared/references/url-patterns.md" + - mono: "../intent-guide.md" + multi: "sheet-intent-guide.md" - mono: references/products/whiteboard/recipes.md multi: dingtalk-misc/references/whiteboard/recipes.md mode: identical +# Directory pairs whose relative file sets and contents must stay byte-identical. +paired_trees: + - mono: references/products/sheet + multi: dingtalk-misc/references/sheet + mode: identical + # Scripts under skills/multi/*/scripts not referenced by any .md in that skill tree. # yida/finance/aiapp are referenced via dingtalk-misc/references/unsupported-scripts.md orphan_scripts_allowlist: diff --git a/skills/mono/references/intent-guide.md b/skills/mono/references/intent-guide.md index 9b25b102..c8b48011 100644 --- a/skills/mono/references/intent-guide.md +++ b/skills/mono/references/intent-guide.md @@ -46,6 +46,7 @@ | "查看这条话题的所有回复" | 话题回复 | `chat +thread-replies` | `chat message list-topic-replies` | 已知 threadId/topicId 时自动投影回复人、正文和时间 | | "把这些消息里的附件下载下来" | 消息资源下载 | 查询 Shortcut 的 `--download-resources` | `resourceRefs.shortcut` 返回的下载命令 | 先用查询结果的 resourceRefs;单资源再执行返回的可运行参数 | | "消息发没发成功/查询消息发送状态" | 查询消息发送状态 | `chat message query-send-status` | — | 需要 send 返回的 openTaskId | +| "编辑/撤回我刚发的消息" | 发后编辑/撤回 | `send` → `query-send-status` → `edit` / `recall` | 按消息内容反查 | 使用 send 返回的 openTaskId 直接获取两个后续 ID | | "撤回我发的消息/撤回消息" | 撤回个人消息 | `chat message recall` | `chat message recall-by-bot` | recall 撤回个人消息,recall-by-bot 撤回机器人消息 | | "未读消息会话/未读会话列表/我的未读会话" | 未读会话列表 | `chat message list-unread-conversations` | `chat message read-status` | list-unread-conversations 查哪些会话有未读;read-status 查具体消息的已读状态 | | "谁看了这条消息/消息已读未读/查读状态" | 查询消息已读状态 | `chat message read-status` | `chat message list-unread-conversations` | read-status 查具体消息的已读人员;list-unread-conversations 查未读会话列表 | @@ -312,6 +313,7 @@ dws chat +messages-send --as user --open-dingtalk-id --msg-type 用 `chat message query-send-status` 的场景: - "消息发没发成功/查询消息发送状态" — 查询个人发送消息的状态,需要 send 返回的 openTaskId +- 发送成功时返回 openMessageId 和 openConversationId,可直接用于 edit/recall 用 `chat +search-msg` 的场景(推荐首选): - "按发送者搜索消息/指定多个群搜索/@我的消息多维度搜" — 支持关键词、发送者、@我、@指定人、多个会话等维度组合搜索 @@ -325,7 +327,7 @@ dws chat +messages-send --as user --open-dingtalk-id --msg-type 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。输出只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。 **不支持的场景**: -- "撤回我刚发的消息"(但不知道消息 ID) — 需先通过消息拉取或搜索接口(如 `chat message list`、`chat message search-advanced` 等)获取 openMessageId,再调用 `chat message recall` +- "撤回历史消息"(且已丢失发送返回的 openTaskId) — 通过消息拉取或搜索接口获取 openMessageId,再调用 `chat message recall` 判断关键:普通发送统一走 `+messages-send` 并显式选择 `--as`;个人撤回→ `recall`;机器人撤回→ `recall-by-bot`;查发送状态→ `query-send-status`;消息搜索类意图优先路由到 `+search-msg`,仅在不适用时降级到原子命令。 diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index e48a6c98..9cccac94 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -653,6 +653,8 @@ dws chat message send --open-dingtalk-id --msg-type file --file #### 查询消息发送状态 — 查询以当前用户身份发送的消息的发送状态 查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。 + +发送成功时返回 openMessageId 和 openConversationId,可直接用于后续编辑或撤回。 ``` Usage: dws chat message query-send-status [flags] @@ -666,6 +668,23 @@ Flags: - openTaskId 由 `dws chat message send` 发送消息成功后返回 - 用于确认消息是否已成功发送或获取发送失败的原因 - 返回结果中含发送成功消息的 openMessageId,可用于后续 recall(撤回)、read-status(查已读)等命令 + - 返回结果同时含 openConversationId;与 openMessageId 组合后可直接用于 edit(编辑)或 recall(撤回) +``` + +发送后编辑/撤回时,优先使用下列 ID 链,无需按消息内容反查: + +```bash +# 1. 发送后保留 openTaskId +dws chat message send --group --text "原始内容" +# 2. 查询得到 openMessageId 和 openConversationId +dws chat message query-send-status --open-task-id +# 3. 编辑消息 +dws chat message edit --conversation-id --msg-id --text "更新后的内容" + +# 发送后撤回使用同一 ID 链 +dws chat message send --group --text "待撤回的内容" +dws chat message query-send-status --open-task-id +dws chat message recall --conversation-id --msg-id ``` #### 撤回消息 — 撤回当前用户自己发出的消息 @@ -676,15 +695,13 @@ Usage: dws chat message recall [flags] Example: dws chat message recall --conversation-id --msg-id - # 查询会话 ID: dws chat search --query "群名" - # 消息 ID 可通过 dws chat message list 获取 Flags: --conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat) --msg-id string 消息 openMessageId (必填) 注意: - --conversation-id 的别名: --group, --id, --chat (均可替代 --conversation-id) - - 消息 ID 可通过 `dws chat message list` 命令获取 + - 刚由 `chat message send` 发出的消息,使用 `query-send-status` 返回的 openConversationId 和 openMessageId;只有历史消息或已丢失 openTaskId 时才通过消息拉取/搜索获取 ID - 仅支持撤回当前用户以个人身份发出的消息,不能撤回他人发送的消息,也不能撤回机器人发出的消息 - 与 `recall-by-bot` 的区别:本命令通过 IM 接口撤回用户自己发出的消息(需要 openConversationId + openMessageId),`recall-by-bot` 通过机器人接口撤回机器人发出的消息(需要 robot-code + processQueryKey) ``` @@ -2417,7 +2434,8 @@ Flags: | `chat bot find` | `botOpenDingTalkId` | 给机器人发单聊消息(send --open-dingtalk-id;字段名是 botOpenDingTalkId,非 openDingTalkId) | | `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys | | `chat message send` | `openTaskId` | query-send-status 的 --open-task-id | -| `chat message list` | `openMessageId` | recall 的 --msg-id | +| `chat message query-send-status` | `openMessageId` + `openConversationId` | 刚发送消息的 edit / recall | +| `chat message list` | `openMessageId` | 历史消息或已丢失 openTaskId 时的 recall | | `chat message search` | `nextCursor` | 下次 message search 的 --cursor | | `chat message search-advanced` | `nextCursor` | 下次 message search-advanced 的 --cursor | | `chat search-common` | `openConversationId` | message send/list 等的 --group | diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 40ccbe53..4276456d 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -1,2160 +1,264 @@ # 电子表格 (sheet) 命令参考 -## 适用范围(重要) +> **渐进式文档**:本文件为路由层(索引 + 意图判断 + 全局约束),各命令的详细参数、示例和注意事项在 [sheet/](./sheet/) 目录下按需加载。 -`sheet` 的单元格与工作表操作仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`)。`sheet import create` 是 Agent 可发现的文件入口:它接收本地 `xlsx` / `xls`,转换并新建在线电子表格;不能把已上传的 xlsx 节点直接当作 axls 操作。 +## 适用范围 + +`sheet` 仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`)。 | 文件类型 | 处理方式 | |---------|---------| -| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) | -| 本地路径上的 `xlsx` / `xls` | 用 `dws sheet import create --file <路径> --folder-token ` 或 `--workspace ` 转换为新的在线电子表格 | -| 钉盘/文档中已有的 `xlsx` / `xls` / `xlsm` / `csv` 节点 | 不能直接调用工作表/单元格命令;先用 `dws doc download --node --output <路径>` 下载。若用户要转为在线表格,再对下载文件执行 `sheet import` | -| 想把在线表格导出为 xlsx | 用 `dws sheet export` ——输入是 `axls`,输出是 xlsx(axls → xlsx 的格式转换) | - -> 用户直接粘贴原始 `alidocs` URL 时必须先 probe:先执行 `dws doc info --node --format json`,按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) 校验 `contentType` 和 `extension`: -> - 仅当 `contentType=ALIDOC` 且 `extension=axls` 时,才继续走 `sheet` -> - 如果是 `xlsx` / `xls` / `xlsm` / `csv`,立即转向 `dws doc download`,并告知用户"这是本地表格文件,已为你下载到本地处理" - -## URL 识别与 NODE_ID 提取 - -当用户输入包含钉钉文档 URL 时,必须先识别并提取 NODE_ID,再判断意图。 - -硬性规则:对用户直接给出的原始 `alidocs` URL,不允许直接走 `sheet` 命令,必须先执行: - -```bash -dws doc info --node "" --format json -``` - -根据返回路由: -- `contentType=ALIDOC` + `extension=axls` → 继续走 `sheet` -- `contentType≠ALIDOC` + `extension=xlsx` / `xls` / `xlsm` / `csv` → 转向 `dws doc download --node --output <路径>`,禁止调用任何 sheet 子命令 -- 其他类型 → 按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) 路由 - -补充:如果这是用户直接提供的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe 一次确认真实类型,再判断是否继续走 `sheet`。 - -### 支持的 URL 格式 - -| 格式 | 示例 | NODE_ID 提取方式 | -|------|------|----------------| -| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段 | -| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段 | -| `alidocs.dingtalk.com/spreadsheetv2/{key}/...` | `https://alidocs.dingtalk.com/spreadsheetv2/vKJngl50tJN1v3a3/...?dentryKey=vKJngl50tJN1v3a3&type=s` | **不要提取 path segment**,将完整 URL 直接传给 `--node` 参数,由 MCP 服务端解析 | - -### 提取规则 - -1. 匹配 URL 中 `alidocs.dingtalk.com` 域名 -2. **判断 URL 路径格式**: - - 路径包含 `/i/nodes/` → 取 URL path 的最后一段作为 NODE_ID(去掉 query string 和 fragment) - - 路径包含 `/spreadsheetv2/` → **不要提取 path segment**,必须将完整 URL 原样传给 `--node` 参数(因为 path 中的短 ID 不是合法的 nodeId,MCP 服务端会自行解析完整 URL) -3. 对于 `/i/nodes/` 格式,提取出的 NODE_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析) -4. 对用户直接提供的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe;只有 probe 确认 `contentType=ALIDOC` 且 `extension=axls` 时,才继续留在 `sheet`;如果 `extension=xlsx` / `xls` / `xlsm` / `csv`,必须转向 `dws doc download`,不能走任何 sheet 命令 - -## 查询命令帮助 - -当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。 - -```bash -# 查看 sheet 下所有子命令 -dws sheet --help - -# 查看具体命令的完整参数说明 -dws sheet range update --help -dws sheet filter-view create --help -dws sheet insert-dimension --help - -# 查看子命令组下的所有命令 -dws sheet range --help -dws sheet filter-view --help -``` - -规则: -- 参数名不确定时 → 先 `--help`,再调用 -- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称 -- 不确定某个功能是否存在时 → `dws sheet --help` 查看命令列表 - -## 命令速查表 - -| 命令 | 用途 | -|------|------| -| `sheet create` | 创建钉钉表格文档 | -| `sheet list` | 获取全部工作表列表 | -| `sheet info` | 获取指定工作表详情 | -| `sheet new` | 新建工作表 | -| `sheet update` | 更新工作表属性(标题/位置/隐藏/冻结) | -| `sheet copy` | 复制工作表 | -| `sheet show-gridline` | 显示工作表网格线 | -| `sheet hide-gridline` | 隐藏工作表网格线 | -| `sheet range read` | 读取工作表数据(别名: range get) | -| `sheet range update` | 更新指定区域内容(值/公式/超链接) | -| `sheet range clear` | 清除区域(值/格式/全部) | -| `sheet range sort` | 对区域排序 | -| `sheet range fill` | 自动填充区域 | -| `sheet range copy-to` | 复制区域到目标位置 | -| `sheet range move-to` | 移动区域到目标位置 | -| `sheet range batch-clear` | 批量清除多个区域(原子事务) | -| `sheet batch-update` | 批量执行多个写操作(原子事务) | -| `sheet csv-get` | 以 CSV 格式读取区域数据 | -| `sheet table-get` | 读取结构化 table 数据(别名: table-read) | -| `sheet range set-style` | 设置单元格样式 | -| `sheet range batch-set-style` | 按配置文件批量设置样式 | -| `sheet find` | 搜索单元格内容 | -| `sheet append` | 在末尾追加数据行 | -| `sheet csv-put` | 将 CSV 数据写入指定位置(支持公式,自动扩容) | -| `sheet table-put` | 写入一个或多个结构化 table(别名: table-write) | -| `sheet pivot-table list` | 列出透视表或获取指定透视表详情 | -| `sheet pivot-table create` | 创建原生透视表 | -| `sheet pivot-table update` | 更新透视表配置 | -| `sheet pivot-table delete` | 删除透视表(不可逆,删除前必须确认) | -| `sheet delete-sheet` | 删除工作表(不可逆,删除前必须确认) | -| `sheet replace` | 全局查找替换文本 | -| `sheet merge-cells` | 合并单元格 | -| `sheet unmerge-cells` | 取消合并单元格 | -| `sheet set-dropdown` | 设置下拉列表 | -| `sheet get-dropdown` | 获取下拉列表配置 | -| `sheet delete-dropdown` | 删除下拉列表 | -| `sheet insert-dimension` | 在指定位置插入行或列 | -| `sheet delete-dimension` | 删除指定位置的行或列 | -| `sheet update-dimension` | 更新行/列属性(显隐/行高/列宽) | -| `sheet move-dimension` | 移动行或列到指定位置 | -| `sheet add-dimension` | 在末尾追加空行或空列 | -| `sheet group-dimension` | 创建行/列分组 | -| `sheet ungroup-dimension` | 取消行/列分组 | -| `sheet media-upload` | 上传附件到表格 | -| `sheet write-image` | 上传图片并写入单元格 | -| `sheet create-float-image` | 创建浮动图片 | -| `sheet get-float-image` | 获取浮动图片详情 | -| `sheet list-float-images` | 列出工作表所有浮动图片 | -| `sheet update-float-image` | 更新浮动图片属性 | -| `sheet delete-float-image` | 删除浮动图片 | -| `sheet import create` | 导入本地 xlsx/xls 为新的在线电子表格(`sheet import` 保留兼容) | -| `sheet export` | 导出表格为 xlsx | -| `sheet filter get` | 获取全局筛选信息 | -| `sheet filter create` | 创建全局筛选 | -| `sheet filter delete` | 删除全局筛选 | -| `sheet filter update` | 批量更新筛选条件 | -| `sheet filter clear-criteria` | 清除单列筛选条件 | -| `sheet filter sort` | 筛选排序 | -| `sheet filter-view list` | 获取所有筛选视图 | -| `sheet filter-view create` | 创建筛选视图 | -| `sheet filter-view update` | 更新筛选视图属性 | -| `sheet filter-view delete` | 删除筛选视图 | -| `sheet filter-view update-criteria` | 更新筛选视图列条件 | -| `sheet filter-view delete-criteria` | 删除筛选视图列条件 | -| `sheet filter-view info` | 获取单个筛选视图详情 | -| `sheet filter-view list-criteria` | 列出筛选视图所有列条件 | -| `sheet filter-view get-criteria` | 获取单列筛选条件详情 | -| `sheet cond-format list` | 获取条件格式规则 | -| `sheet cond-format create` | 创建条件格式规则 | -| `sheet cond-format update` | 更新条件格式规则 | -| `sheet cond-format delete` | 删除条件格式规则 | -| `sheet chart list` | 获取浮动图表 | -| `sheet chart create` | 创建浮动图表 | -| `sheet chart update` | 更新浮动图表 | -| `sheet chart delete` | 删除浮动图表 | -| `sheet template list` | 获取表格模板列表 | -| `sheet template search` | 搜索表格模板 | -| `sheet template apply` | 应用模板创建新表格文档 | - -> 不确定参数?对任意命令执行 `dws sheet <命令> --help` 查看完整用法。 - -## 子文档索引(更多命令详细参考) - -本文档覆盖高频命令。以下命令的完整参数、示例与工作流在 `sheet/` 子目录,需要时按主题查阅: - -| 主题 | 子文档 | 覆盖命令 | -|------|--------|---------| -| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / show-gridline / hide-gridline / delete-sheet | -| 透视表 | [sheet/sheet-pivot-table.md](./sheet/sheet-pivot-table.md) | pivot-table list / create / update / delete | -| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | table-put / range update(对象协议详解)/ append / csv-put | -| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | table-get / range read / csv-get | -| 区域操作 | [sheet/sheet-range-operations.md](./sheet/sheet-range-operations.md) | range clear / sort / fill / copy-to / move-to | -| 批量操作 | [sheet/sheet-batch-operations.md](./sheet/sheet-batch-operations.md) | range batch-clear / batch-update | -| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension | -| 样式与合并 | [sheet/sheet-style-format.md](./sheet/sheet-style-format.md) | range set-style / batch-set-style / merge-cells / unmerge-cells | -| 条件格式 | [sheet/sheet-conditional-format.md](./sheet/sheet-conditional-format.md) | cond-format list / create / update / delete | -| 浮动图表 | [sheet/sheet-chart.md](./sheet/sheet-chart.md) | chart list / create / update / delete | -| 下拉列表 | [sheet/sheet-dropdown.md](./sheet/sheet-dropdown.md) | set / get / delete-dropdown | -| 媒体与图片 | [sheet/sheet-media-image.md](./sheet/sheet-media-image.md) | media-upload / write-image / 浮动图片 | -| 查找替换 | [sheet/sheet-search-replace.md](./sheet/sheet-search-replace.md) | find / replace | -| 全局筛选 | [sheet/sheet-filter.md](./sheet/sheet-filter.md) | filter get / create / delete / update / clear-criteria / sort | -| 筛选视图 | [sheet/sheet-filter-view.md](./sheet/sheet-filter-view.md) | filter-view 全系列 | -| 导入 | [sheet/sheet-import.md](./sheet/sheet-import.md) | import / import get | -| 导出 | [sheet/sheet-export.md](./sheet/sheet-export.md) | export | - -> `template list/search/apply` 无独立子文档,直接执行 `dws sheet template <子命令> --help` 查看用法。 - -## 意图判断 - -### 表格与工作表管理 - -用户说"创建表格/新建电子表格": -- 创建表格文档 → `create` - -用户说"看工作表/有哪些工作表/表格结构": -- 列出工作表 → `list` -- 工作表详情 → `info` - -用户说"加工作表/新增Sheet": -- 新建工作表 → `new` - -用户说"修改工作表名称/重命名工作表/移动工作表位置/隐藏工作表/显示工作表/冻结行/冻结列/取消冻结/更新工作表属性": -- 更新工作表属性 → `update` -- 重命名工作表 → `update --title "新名称"` -- 移动工作表位置 → `update --index N` -- 隐藏工作表 → `update --hidden` -- 显示工作表 → `update --hidden=false` -- 冻结行列 → `update --frozen-row-count N --frozen-column-count M` -- 取消冻结 → `update --frozen-row-count 0 --frozen-column-count 0` - -用户说"复制工作表/拷贝工作表/克隆工作表/工作表副本": -- 复制工作表 → `copy` -- 复制并指定名称 → `copy --title "副本名称"` -- 复制并指定位置 → `copy --index N` - -### 数据读写 - -用户说"读数据/看表格内容": -- 读取数据 → `range read` - -用户说"写数据/填表/更新单元格/写入公式": -- 更新数据 → `range update` -- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等 -- 注意:如果用户的目的是替换文本、移动行列或追加空行空列,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`) -- **批量 CSV 值/公式写入优先用 `csv-put`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富格式且数据量较大(超过 5 行或超过 20 个单元格)时,必须优先使用 `csv-put`。它无需手动构造二维 JSON 数组并支持自动扩容;要写以 `=` 开头的字面文本时在字段值前加单引号 - -用户说"追加数据/添加行/在末尾加数据/新增记录": -- 追加数据 → `append` - -用户说"批量写入CSV/导入CSV/CSV写入表格/把CSV贴到表格里": -- 写入 CSV → `csv-put` -- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量值或公式写入,但不支持超链接和富格式对象 -- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加 - -用户说"搜索/查找/找单元格/搜内容/精确搜索/精确匹配/完全匹配/全字匹配": -- 搜索单元格 → `find` -- 精确匹配(只匹配完全等于的,不匹配包含的) → `find --match-entire-cell` -- 正则搜索 → `find --use-regexp` -- 搜索公式 → `find --match-formula` -- 不要用 `range read` 读取全量数据后在客户端过滤来替代 `find`,必须使用 `find` 命令的服务端搜索能力 - -用户说"替换/查找替换/全局替换/批量替换/把A替换成B/把所有的X改成Y": -- 查找替换 → `replace` -- 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell` -- 正则替换 → `replace --use-regexp` -- 删除匹配内容 → `replace --replacement ""` -- 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数 - -### 行列操作 - -用户说"插入行/插入列/在某行前插入/在某列前插入": -- 插入行或列 → `insert-dimension` -- 在末尾追加 → `append`(insert-dimension 不支持末尾追加) - -用户说"删除行/删除列/删掉第几行/删掉某列/移除行/移除列": -- 删除行或列 → `delete-dimension` -- 仅清空内容但保留行/列 → `range clear`(整片区域清除,比逐格写空更简洁) - -用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性": -- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false` -- 设置行高/列宽 → `update-dimension --pixel-size` -- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden` - -用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行": -- 移动行或列 → `move-dimension` -- 请勿用 `range read` + `range update` 读取再重写来模拟移动,`move-dimension` 是原子操作,能保留格式和合并状态 - -用户说"追加空行/追加空列/增加行数/增加列数/扩展表格/在末尾加空行": -- 追加空行/空列 → `add-dimension` -- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 -- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 - -用户说"创建行分组/创建列分组/折叠几行/折叠几列/取消行列分组": -- 创建分组 → `group-dimension --range <整行或整列范围>` -- 取消分组 → `ungroup-dimension --range <整行或整列范围>` -- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不要传 `A1:C5` - -### 单元格格式 - -用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号": -- 设置单元格样式 → `range set-style` -- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(内部顺序循环调 `update_range`) -- 请勿用 `range update --values` 写空/重写来模拟样式变更;也请勿把样式变更混在 `range update` 里、再故意清空 `--values` - -用户说"设置数字格式/改成百分比/用人民币显示/按日期显示/文本格式/保留几位小数": -- 设置数字格式 → `range set-style --number-format <格式代码>`(如 `0%` / `¥#,##0.00` / `yyyy/m/d` / `@`) -- 请勿用 `range update` 传递数字格式,`range update` 仅负责写入值与超链接;数字格式属于单元格样式,统一走 `range set-style` - -用户说"合并单元格/合并/合并区域/按行合并/按列合并": -- 合并所有单元格 → `merge-cells`(默认 mergeAll) -- 按行合并 → `merge-cells --merge-type mergeRows` -- 按列合并 → `merge-cells --merge-type mergeColumns` - -用户说"取消合并/拆分单元格/还原合并": -- 取消合并单元格 → `unmerge-cells` - -### 下拉列表 - -用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉": -- 设置下拉列表 → `set-dropdown` -- 设置多选下拉 → `set-dropdown --multi-select` - -用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项": -- 获取下拉列表配置 → `get-dropdown` - -用户说"删除下拉列表/移除下拉/取消下拉/清除下拉": -- 删除下拉列表 → `delete-dropdown` - -### 媒体上传 - -用户说"上传附件/传文件到表格/上传文件到表格/上传到表格": -- 上传附件 → `media-upload`(需表格 ID 或 URL + 本地文件路径) -- 用户指定了上传后的名称 → `media-upload --name "自定义名称"` -- `media-upload` 的 `--name` 参数用于指定附件在表格中显示的名称(不改变本地文件名);不传时默认使用本地文件名 - -用户说"写入图片/插入图片/加图片/放图片到单元格/嵌入图片到表格": -- 写入图片 → `write-image`(需表格 ID + 工作表 ID + 单元格范围 + 本地图片路径) -- 禁止使用 `range update` 写入图片,因为 `update_range` 的 MCP 工具不支持图片类型参数,调用必定失败。必须使用 `write-image` 命令 -- 用户指定了图片尺寸 → `write-image --width N --height M` - -### 浮动图片 - -用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图": -- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image` -- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同 - -用户说"查看浮动图片/有哪些浮动图片/浮动图片列表": -- 列出所有浮动图片 → `list-float-images` -- 查看某个浮动图片详情 → `get-float-image` - -用户说"移动浮动图片/调整浮动图片大小/修改浮动图片/更新浮动图片": -- 更新浮动图片属性 → `update-float-image`(可更新锚点位置、尺寸、偏移量、图片资源路径) - -用户说"删除浮动图片/移除浮动图片": -- 删除浮动图片 → `delete-float-image` - -关键区分:`write-image`(单元格内嵌图片,占据单元格内容)vs `create-float-image`(浮动图片,悬浮于单元格之上,不占内容) - -### 筛选视图 - -用户说"筛选/过滤/只看某些值/只显示满足条件的行/筛选数据/创建筛选/删除筛选/设置筛选条件/清除筛选/排序": -- 查看当前筛选 → `filter get` -- 创建筛选 → `filter create` -- 删除筛选 → `filter delete` -- 批量设置多列条件 → `filter update` -- 清除某一列条件 → `filter clear-criteria` -- 按列排序 → `filter sort` -- **区分全局筛选与筛选视图**:如果用户说"筛选视图"则走 `filter-view` 系列;如果只说"筛选/过滤/只看"则默认走全局 `filter` 系列 -- **禁止替代方案**:当用户要求"筛选/只看/仅保留某些行"时,必须通过 `filter create` / `filter update` 创建真实的筛选器。禁止用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替——这些做法会让原数据丢失或不可恢复 - -用户说"筛选视图/查看筛选视图/有哪些筛选视图/筛选视图列表": -- 获取所有筛选视图 → `filter-view list` - -用户说"筛选视图详情/查看某个筛选视图/筛选视图信息/筛选视图配置": -- 获取单个筛选视图详情 → `filter-view info` - -用户说"创建筛选视图/新建筛选视图/添加筛选视图": -- 创建筛选视图 → `filter-view create` - -用户说"更新筛选视图/修改筛选视图/改筛选视图名称/改筛选视图范围": -- 更新筛选视图属性 → `filter-view update` - -用户说"删除筛选视图/移除筛选视图": -- 删除筛选视图 → `filter-view delete` - -用户说"设置筛选条件/添加筛选条件/配置筛选视图条件/按值筛选/按条件筛选/按颜色筛选": -- 设置筛选视图列条件 → `filter-view update-criteria` - -用户说"查看筛选条件/有哪些筛选条件/筛选视图设了什么条件/列出筛选条件": -- 列出所有列条件 → `filter-view list-criteria` -- 查看某一列的条件 → `filter-view get-criteria --column N` - -用户说"清除筛选条件/移除筛选条件/取消筛选条件": -- 清除筛选视图列条件 → `filter-view delete-criteria` -- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身 - -### 导入 - -用户说"导入Excel/把xlsx转为在线表格/上传本地表格并在线编辑": -- 使用 `sheet import`,本地文件仅支持 `xlsx` / `xls` -- `--folder-token` 与 `--workspace` 至少提供一个;不指定 `--name` 时使用文件名 -- CLI 内部完成上传、转换和轮询,Agent 不要自行拆分流程或重复提交 -- 若返回 `timed_out:true`,执行返回的 `next_command` 续查,不要重新执行 import -- 详见 [sheet/sheet-import.md](./sheet/sheet-import.md) - -### 导出 - -用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel": -- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载) -- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl) -- 需要落盘到本地 → `dws sheet export --node --output `,命令自动下载 xlsx -- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) -- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) - -### URL 粘贴场景 +| 在线电子表格(`axls`) | 走 `sheet` 全部命令 | +| `xlsx` / `xls` / `xlsm` / `csv` | `dws drive download --node --output <路径>` 下载到本地处理,禁止调用任何 `sheet` 子命令 | +| 本地 xlsx/xls 导入为在线表格 | `dws sheet import create --file <路径>`(单命令一站式:创建会话→上传→确认→轮询,产出在线电子表格;`sheet import` 保留兼容) | +| 在线表格导出为 xlsx | `dws sheet export`(axls → xlsx 格式转换) | + +用户贴原始 `alidocs` URL 时必须先 probe:`dws drive info --node --format json`,按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) 校验: +- `extension=axls` → 继续走 `sheet` +- `extension=xlsx` / `xls` / `xlsm` / `csv` → 转 `dws drive download`,告知用户"这是本地表格文件,已为你下载到本地处理" + +## URL → NODE_ID + +| URL 格式 | 提取方式 | +|----------|---------| +| `.../i/nodes/{id}` 或 `.../i/nodes/{id}?query` | 取路径末段作 NODE_ID(忽略 query) | +| `.../spreadsheetv2/{key}/...` | **完整 URL 原样传 `--node`**,禁止提取 path segment | + +参数不确定时先 `dws sheet <命令> --help`。 + +## Agent 编辑总则 + +1. **先定位再编辑**:未知 `sheet-id` 时必须先 `dws sheet list --node --format json`,禁止猜 `Sheet1` / `sheet1` / `0` / `default`。用户只给工作表名称时,也先用 `list` 确认真实名称或 ID。 +2. **先读结构再动结构**:合并、冻结、行列分组、行高列宽、隐藏行列、最后非空边界都属于工作表结构信息,先读 `dws sheet info --node --sheet-id --format json`;分组回读加 `--include groups`,行高、列宽和隐藏行列按需加 `--include row_heights,col_widths,hidden_rows,hidden_cols`。 +3. **按目的选择读取方式**:快速看值和大表分批用 `csv-get`;需要 `columns` / `data` / `dtypes` / `formats` 用 `table-get`;需要公式、样式、数据验证、超链接、富文本等 per-cell 元数据用 `range read`。 +4. **写返回不等于完成**:任何写操作完成后都要用独立读命令回读确认。值写入用 `csv-get` / `range read` / `table-get`,结构变更用 `sheet info`,对象类操作用对应 `list` / `get`。 +5. **大批量 CSV 值/公式不要拼大 JSON**:超过 5 行或 20 个单元格、且不需要富格式对象时优先 `csv-put`;字段值以 `=` 开头按公式解析,前加单引号写入以 `=` 开头的字面文本;需要 table/dataframe 语义时用 `table-put`。 +6. **公式写入后分层校验**:写公式前读 [sheet-formula](./sheet/sheet-formula.md)。写后先用 `range read --value-render-option formula` 确认公式文本,再用 `formula-verify` 聚合扫描计算错误;需要确认业务数值时再用 `raw_value` 抽样对账。 +7. **专用操作用专用命令**:搜索用 `find`、替换用 `replace`、清空用 `range clear`、排序用 `range sort`、复制/移动区域用 `range copy-to` / `range move-to`,不要用 `range read` + `range update` 客户端模拟。 +8. **大整数按文本写**:超过 `9007199254740991` 的整数、长数字 ID、订单号、手机号等需要逐位精确的值,不要按 JSON number 或 `int64` / `uint64` 写入;用字符串值 + `object` dtype + 文本格式 `@`。 + +## 场景速查 + +| 用户要做 | 正确命令 | 动手前读 | 禁止写法 | +|---------|---------|----------|----------| +| 快速查看数据 / 大表分批读取 | `csv-get` | [sheet-read-data](sheet/sheet-read-data.md) | 不传 `--range` 全量 `range read` 大表 | +| 按表格结构读写列名、类型、格式 | `table-get` / `table-put` | [sheet-read-data](sheet/sheet-read-data.md)、[sheet-write-data](sheet/sheet-write-data.md) | 把 table spec 塞进 `batch-update` | +| 少量精确写入、公式对象、超链接、富文本、数据验证 | `range update` | [sheet-write-data](sheet/sheet-write-data.md)、[sheet-formula](sheet/sheet-formula.md) | 用 `append` 写公式,或用 `csv-put` 写富格式 | +| 校验公式错误 / 全表公式检查 | `formula-verify` | [sheet-formula](sheet/sheet-formula.md) | 全表 `range read` 后由 Agent 自行枚举错误值 | +| 批量 CSV 值/公式写入 / CSV 粘贴 | `csv-put` | [sheet-write-data](sheet/sheet-write-data.md)、[sheet-formula](sheet/sheet-formula.md) | 为大块 CSV 数据手写巨大 `--values` JSON | +| 追加记录到末尾 | `append` | [sheet-write-data](sheet/sheet-write-data.md) | 手算最后一行后 `range update` | +| 查找 / 替换 | `find` / `replace` | [sheet-search-replace](sheet/sheet-search-replace.md) | 读全表后本地过滤或手写替换 | +| 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 | +| 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 | +| 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 | +| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | +| 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | +| 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | + +## 触发必读 + +- 涉及公式、计算列、辅助列、占比、增长率、查找计算、公式校验、公式错误扫描:先读 [sheet-formula](./sheet/sheet-formula.md)。 +- 涉及 `range update --values`、超链接、富文本、数据验证、少量样式随值写入:先读 [sheet-write-data](./sheet/sheet-write-data.md)。 +- 涉及公式文本 / 原始值 / 格式化值回读、per-cell 元数据、大表分页:先读 [sheet-read-data](./sheet/sheet-read-data.md)。 +- 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。 +- 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。 +- 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。 + +## Reference 索引 + +| Reference | 描述 | +|-----------|------| +| [sheet-workbook](sheet/sheet-workbook.md) | 管理表格文档与工作表。当用户说"创建表格"、"有哪些工作表"、"新建/重命名/隐藏/冻结/复制/删除工作表"、"显示/隐藏网格线"时使用;`info` 可回读冻结行列,`--include groups` 回读分组,其他行列结构按需使用对应 `--include`。命令:`create`/`list`/`info`/`new`/`update`/`copy`/`delete-sheet`/`show-gridline`/`hide-gridline` | +| [sheet-read-data](sheet/sheet-read-data.md) | 读取工作表数据。当用户说"读数据"、"看表格内容"、"查看数据"时使用。推荐 `csv-get`(CSV 格式、token 低、防爆保护);需按 table/dataframe 结构读取列名、data、dtypes、formats 时用 `table-get`;需 value + dataValidation / hyperlink / richText / cellStyles 等 per-cell 元数据时用 `range read`。大范围数据建议分页读取(单次 ≤5000 单元格)。命令:`csv-get`/`table-get`/`range read` | +| [sheet-write-data](sheet/sheet-write-data.md) | 写入数据到工作表。当用户说"写数据"、"填表"、"更新单元格"、"写公式"、"超链接"、"写值同时设样式/数据验证"、"追加数据"、"导入CSV"、"写入结构化 table"时使用。大批量 CSV 值/公式(>5行或>20单元格,且无需富格式)必须用 `csv-put` 而非 `range update`;结构化 table/dataframe 输入用 `table-put`。命令:`range update`/`append`/`csv-put`/`table-put` | +| [sheet-formula](sheet/sheet-formula.md) | 公式写入、文本回读、错误聚合与结果抽样。当用户说"写公式"、"计算列"、"辅助列"、"总计/占比/增长率/查找计算"、"校验公式"、"检查公式错误"时使用。命令:`range update` / `csv-put` / `range read --value-render-option formula/raw_value` / `formula-verify` | +| [sheet-search-replace](sheet/sheet-search-replace.md) | 搜索和替换文本。当用户说"搜索"、"查找"、"替换"、"把A改成B"时使用。禁止用 `range read` 全量读取后客户端过滤代替 `find`,禁止用 `range update` 模拟 `replace`。命令:`find`/`replace` | +| [sheet-range-operations](sheet/sheet-range-operations.md) | 区域结构性操作。当用户说"清空"、"排序"、"自动填充"、"复制区域到"、"移动数据到"时使用。均为服务端原子操作,禁止 `range read`+`range update` 组合模拟。排序前必须先读前几行判断表头。命令:`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to` | +| [sheet-batch-operations](sheet/sheet-batch-operations.md) | 批量操作。当用户说"批量清除多个区域"、"组合多个写操作"、"先清除再写入"、"插行列再写数据"、"批量创建/取消分组"时使用。`batch-update` 只支持已列明的原子写操作;`table-put` / `table-get` 不放进 batch,结构化 table 请用独立 `table-put`。命令:`range batch-clear`/`batch-update` | +| [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` | +| [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` | +| [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` | +| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | +| [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | +| [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | +| [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | +| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` | +| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` | +| [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` | +| [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` | +| [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` | +| sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` | +| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` | + +## 全局硬约束 + +1. **`--sheet-id` 禁止臆测**:未知时必须 `dws sheet list --node --format json` 查询,禁止编造 `Sheet1`/`sheet1`/`0`/`default` +2. **合并单元格是结构信息**:`dws sheet info --node --sheet-id --format json` 返回 `mergedRanges`(如 `["C7:D11"]`);不要在 `range read` / `csv-get` 里寻找合并信息 +3. **冻结行列是工作表元数据**:`sheet info` 顶层返回 `frozenRowCount` / `frozenColumnCount`,分别表示从顶部第 1 行、左侧第 A 列开始冻结的数量;`0` 表示未冻结。不要在 `range read` / `csv-get` 里寻找冻结信息 +4. **结构变更后按需 include 回读**:创建/取消分组后,用 `dws sheet info --node --sheet-id --include groups --format json` 回读 `rowGroups` / `columnGroups`;行高、列宽、隐藏行列分别使用 `--include row_heights` / `col_widths` / `hidden_rows` / `hidden_cols`。裸 `sheet info` 不承诺返回这些可选结构字段。 +5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn` +6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"` +7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV +8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段 +9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段 +10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension` +11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put` +12. **单元格图片用 `write-image`**(`range update` 不支持图片参数) +13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 +14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) +15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@` +16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update` +17. **关键区分**:sheet(电子表格/单元格读写)vs aitable(AI多维表/结构化记录)vs doc(文档) + +## 当前能力边界 + +- **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。 +- **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。 +- **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。 +- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。 +- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。 +- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 + +## URL 粘贴场景 用户直接粘贴表格 URL(无其他指令): -- 先 probe:`dws doc info --node --format json` 校验 `contentType` 和 `extension` -- `extension=axls` → `list`(列出工作表)+ `range read`(读取第一个工作表数据) -- `extension=xlsx` / `xls` / `xlsm` / `csv` → 转 `dws doc download --node --output ./`,告知用户"这是本地表格文件,已为你下载到本地",然后基于本地文件继续后续处理 +- 先 probe:`dws drive info --node --format json` +- `extension=axls` → `list` + `range read`(读取第一个工作表数据) +- `extension=xlsx`/`xls`/`xlsm`/`csv` → 转 `dws drive download --node --output ./` 用户粘贴 URL + 附加指令: -- 已 probe 为 `axls` 时: - - "帮我看看这个表格有什么数据" → `range read` - - "这个表格有哪些工作表" → `list` - - "往这个表格写入数据" → `range update` - - "帮我找一下表格里的XXX" → `find` -- probe 为 xlsx/xls/xlsm/csv 时:无论用户说"读数据/查看/分析",先走 `dws doc download` 下载到本地,由用户或后续步骤对本地 xlsx 进行解析,严禁调用 `sheet list` / `range read` 等命令 +- probe 为 `axls` → 按 Reference 索引路由到对应命令 +- probe 为 xlsx/csv → 先 `dws drive download` 下载到本地,严禁调用 sheet 命令 -关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录) vs doc(文档编辑/阅读) - -## 关键注意事项 - -以下是最容易出错的规则,**必须严格遵守**: - -- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- ★ **`range update` 单元格协议(强制)**:`--values` 是二维 JSON 数组,**每个单元格必须是 object**(如 `{"type":"text","text":"张三"}`),不再支持裸值 `"张三"` / `90` / `null`。裸值会报错「不支持原始值……每个单元格必须是 object」。数字/布尔也写成字符串 object(如 `{"type":"text","text":"90"}`),服务端自动识别类型。超链接写在单元格 object 的 `hyperlink` 字段,**没有 `--hyperlinks` flag**(传了报 unknown flag) -- ★ **`range update` 维度校验(强制)**:`--values` 的行列数必须与 `--range` 完全一致。例如 `--range "A1:C3"` → `--values` 必须是 3×3 的 object 数组 -- ★ **`range update` 清空规范(强制)**:清空单个单元格用 `{"type":"text","text":""}`;清空整片区域用 `range clear`。跳过某格保留原值用 `{}` 空对象 -- ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) -- ★ **大批量 CSV 值/公式写入用 `csv-put` 不用 `range update`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富格式且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。需要超链接、富格式对象、`{}` 跳过,或仅更新少量单元格时使用 `range update` -- ★ **搜索用 `find` 不用 `range read`**:`find` 是服务端搜索,禁止用 `range read` 全量读取后客户端过滤 -- ★ **替换用 `replace` 不用 `range update`**:`replace` 是服务端原子操作,返回替换计数 -- ★ **移动用 `move-dimension` 不用 `range update`**:原子操作,保留格式和合并状态 -- ★ **单元格图片用 `write-image` 不用 `range update`**:`update_range` MCP 不支持图片参数,调用必失败 -- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl` -- ★ **`export` 禁止自行轮询/重试**:CLI 内部已完成渐进式退避轮询(最多 30 次约 5 分钟),失败时直接告知用户稍后再试 -- ★ **关键区分**:sheet(在线电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读) - -> 完整注意事项请参见本文档末尾「注意事项(完整版)」章节。 - -## 命令详细参考 - -> 以下为各命令的完整 Usage、Flags 和示例。参数不确定时也可直接执行 `dws sheet <命令> --help` 在线查看。 - -### 创建钉钉表格文档 -``` -Usage: - dws sheet create [flags] -Example: - dws sheet create --name "销售数据" - dws sheet create --name "Q1 数据" --folder - dws sheet create --name "知识库表格" --workspace -Flags: - --name string 表格名称 (必填) - --folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId - --workspace string 目标知识库 ID -``` - -> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 - -### 获取全部工作表列表 -``` -Usage: - dws sheet list [flags] -Example: - dws sheet list --node - dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/" -Flags: - --node string 表格文档 ID 或 URL (必填) -``` - -### 获取指定工作表详情 -``` -Usage: - dws sheet info [flags] -Example: - dws sheet info --node - dws sheet info --node --sheet-id - dws sheet info --node --sheet-id "Sheet1" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (不传则返回第一个工作表) -``` - -### 新建工作表 -``` -Usage: - dws sheet new [flags] -Example: - dws sheet new --node --name "Sheet2" - dws sheet new --node --name "数据汇总" -Flags: - --node string 表格文档 ID (必填) - --name string 工作表名称 (必填) -``` - -### 更新工作表属性 -``` -Usage: - dws sheet update [flags] -Example: - # 改名 + 调整冻结 - dws sheet update --node --sheet-id --title "汇总表" --frozen-row-count 2 --frozen-column-count 1 - - # 隐藏工作表 - dws sheet update --node --sheet-id --hidden=true - - # 显示工作表 - dws sheet update --node --sheet-id --hidden=false - - # 移动工作表到第一个位置 - dws sheet update --node --sheet-id --index 0 - - # 取消冻结 - dws sheet update --node --sheet-id --frozen-row-count 0 --frozen-column-count 0 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --name string 新名称,最长 100 字符,不能包含 / \ ? * [ ] : - --title string --name 的别名(兼容) - --index int 新位置(从 0 开始) - --hidden --hidden=true 隐藏,--hidden=false 取消隐藏 - --tab-color string 工作表标签颜色,Hex 如 #FF0000;传空字符串清除颜色 - --frozen-row-count int 冻结行数,0 表示取消冻结 - --frozen-column-count int 冻结列数,0 表示取消冻结 -``` - -更新工作表名称、位置、隐藏状态、标签颜色、冻结行列。 -`--name`(别名 `--title`)/ `--index` / `--hidden` / `--tab-color` / `--frozen-row-count` / `--frozen-column-count` 至少提供一个;多个属性可同时传入,将在同一次请求中更新。 - -注意: -- 至少需要保留一个可见的工作表,不能将所有工作表都隐藏 -- 冻结行数/列数不能超过工作表的总行数/列数 - -### 复制工作表 -``` -Usage: - dws sheet copy [flags] -Example: - # 按默认位置复制 - dws sheet copy --node --sheet-id - - # 指定副本名称和位置 - dws sheet copy --node --sheet-id --title "销售副本" --index 2 - - # 只指定名称 - dws sheet copy --node --sheet-id --title "备份" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 源工作表 ID 或名称 (必填) - --title string 副本名称,最长 100 字符,不能包含 / \ ? * [ ] : (不传则系统自动生成) - --index int 副本位置(从 0 开始)(不传则放在源工作表之后) -``` - -复制指定工作表,在同一表格中创建一个副本。 -复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表中。 -传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引。 -名称与已有工作表重复时系统会自动重命名。 - -### 读取工作表数据 -``` -Usage: - dws sheet range read [flags] # 别名: dws sheet range get -Example: - dws sheet range read --node - dws sheet range read --node --sheet-id - dws sheet range read --node --sheet-id "Sheet1" --range "A1:D10" - dws sheet range read --node --range "Sheet1!A1:D10" - - # 使用 get 别名,与 read 等价 - dws sheet range get --node --sheet-id --range "A1:D10" - - # 指定取值模式:原始值 / 公式文本 - dws sheet range read --node --value-render-option raw_value - dws sheet range read --node --value-render-option formula -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表) - --range string 读取范围,A1 表示法 (如 A1:D10,不传则读取全部数据) - --value-render-option string 取值模式: formatted_value(格式化展示值,默认) | raw_value(原始值) | formula(公式文本,无公式回退原始值) -``` - -**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: -- 先通过 `info` 获取 `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定数据边界(空表时这些字段为 null) -- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… -- 避免不传 `--range` 直接读取整个大工作表 - -### 更新工作表指定区域内容 -``` -Usage: - dws sheet range update [flags] -Example: - # 写入文本(每个单元格必须是 object) - dws sheet range update --node --sheet-id --range "A1:B2" \ - --values '[[{"type":"text","text":"姓名"},{"type":"text","text":"分数"}],[{"type":"text","text":"张三"},{"type":"text","text":"90"}]]' - - # 写入公式(text 以 = 开头识别为公式) - dws sheet range update --node --sheet-id --range "C2" \ - --values '[[{"type":"text","text":"=A2&B2"}]]' - - # 写入单元格级超链接(写在 cell 的 hyperlink 字段,没有 --hyperlinks flag) - dws sheet range update --node --sheet-id --range "A1" \ - --values '[[{"type":"text","text":"钉钉","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]' - - # 只更新部分单元格:用 {} 空对象占位保留原值 - dws sheet range update --node --sheet-id --range "A1:B1" \ - --values '[[{"type":"text","text":"新值"},{}]]' - - # 清空单个单元格(text 为空字符串) - dws sheet range update --node --sheet-id --range "A1" \ - --values '[[{"type":"text","text":""}]]' - - # 清空整片区域请改用 range clear - dws sheet range clear --node --sheet-id --range "A1:B3" -Flags: - --node string 表格文档 ID (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格区域地址,如 A1:B3 (必填) - --values string 单元格内容,二维 JSON 数组 (必填);每个元素必须是 object -``` - -**单元格对象协议(重要)**:`--values` 每个元素必须是 object,支持以下形态: -- `{"type":"text","text":"内容"}`:普通文本;`text` 以 `=` 开头识别为公式;`text` 为 `""` 清空该格;数字/布尔写成字符串(`{"type":"text","text":"100"}`),服务端自动识别类型 -- `{"type":"text","text":"内容","cellStyles":{...}}`:文本 + 整格样式(`fontWeight`/`fontColor`/`backgroundColor`/`numberFormat` 等) -- `{"type":"richText","texts":[...]}`:富文本,子项可为 text/link/attachment/image,片段样式写在子项 `style` -- `{"type":"text","text":"钉钉","hyperlink":{"type":"path","link":"https://..."}}`:整格超链接(`type` 可为 `path`/`sheet`/`range`,或 `{"type":"none"}` 清除) -- `{"dataValidation":{...}}`:写下拉/复选框数据验证 -- `{}`:空对象,跳过该格保留原值(只改部分单元格时占位用) - -不再支持裸值(`"张三"` / `90` / `null`),原样传入会报「不支持原始值……每个单元格必须是 object」。 - -**单次调用建议**:行数 ≤ 1000,单元格总数(行×列)≤ 5000;超过时请拆分多次调用。 - -**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入大批量值或公式,数据可以表达为 CSV,且不需要超链接或富文本,应改用 `csv-put`——它无需手动拼装 object 数组,且支持自动扩容行列。需要富格式对象、`{}` 跳过或修改少量单元格时使用 `range update`。 - -**范围职责**:`range update` 负责写入单元格的值、超链接、per-cell 样式与数据验证。批量刷整片区域的统一样式或数字格式(百分比 / 货币 / 日期 / 文本等)请使用 `dws sheet range set-style --number-format <格式代码>`。 - -### 设置单元格样式 -``` -Usage: - dws sheet range set-style [flags] -Example: - # 给 A1:B3 打上黄底粗体居中 - dws sheet range set-style --node --sheet-id --range "A1:B3" \ - --bg-color "#FFF2CC" --font-weight bold --h-align center - - # 给 C1:C5 逐单元格设置不同背景色 - dws sheet range set-style --node --sheet-id --range "C1:C5" \ - --bg-colors-json '[["#FF0000"],["#00FF00"],["#0000FF"],["#FFFF00"],["#FF00FF"]]' - - # 整片 range 启用自动换行 - dws sheet range set-style --node --sheet-id --range "A1:E10" --word-wrap autoWrap -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标区域,如 A1:B3 (必填) - --bg-color string 背景色(#RRGGBB),一键刷整个 range;与 --bg-colors-json 二选一 - --bg-colors-json string 背景色二维 JSON 数组,维度需与 --range 一致 - --font-size int 字号,一键刷整个 range;与 --font-sizes-json 二选一 - --font-sizes-json string 字号二维 JSON 数组 - --h-align string 水平对齐:left/center/right/general - --h-aligns-json string 水平对齐二维 JSON 数组 - --v-align string 垂直对齐:top/middle/bottom - --v-aligns-json string 垂直对齐二维 JSON 数组 - --font-color string 字体颜色(#RRGGBB) - --font-colors-json string 字体颜色二维 JSON 数组 - --font-weight string 字体粗细:bold/normal - --font-weights-json string 字体粗细二维 JSON 数组 - --word-wrap string 换行方式:overflow/clip/autoWrap(整个 range 共用) - --number-format string 数字格式,如 General/@/#,##0/0%/yyyy/m/d -``` - -**特性说明**: -- 每个样式维度提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致) -- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000 -- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center` 等 - -### 批量设置单元格样式 -``` -Usage: - dws sheet range batch-set-style [flags] -Example: - dws sheet range batch-set-style --node --batch ./styles.json - dws sheet range batch-set-style --node --batch ./styles.json --continue-on-error -Flags: - --node string 表格文档 ID 或 URL (必填) - --batch string 批次配置 JSON 文件路径 (必填) - --continue-on-error 遇到失败时继续执行后续条目(默认遇错即停) -``` - -配置文件格式(JSON 数组,每个元素一条批次项): -```json -[ - { - "sheetId": "Sheet1", - "range": "A1:B3", - "bgColor": "#FFF2CC", - "fontSize": 12, - "hAlign": "center", - "vAlign": "middle", - "fontColor": "#333333", - "fontWeight": "bold", - "wordWrap": "autoWrap", - "numberFormat": "General" - }, - { - "sheetId": "Sheet1", - "range": "C1:C5", - "bgColorsJson": "[[\"#FF0000\"],[\"#00FF00\"],[\"#0000FF\"],[\"#FFFF00\"],[\"#FF00FF\"]]" - } -] -``` - -**特性说明**: -- CLI 侧顺序循环逐条调用 `update_range`(非服务端批量),运行时输出 `[N/M]` 进度 -- 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法 -- 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误 - -### 在工作表中搜索单元格内容 -``` -Usage: - dws sheet find [flags] -Example: - # 基本搜索 - dws sheet find --node --sheet-id --find "销售额" - - # 在指定范围内搜索 - dws sheet find --node --sheet-id --find "合计" --range "A1:D100" - - # 正则表达式搜索(不区分大小写) - dws sheet find --node --sheet-id --find "^total" --use-regexp --match-case=false - - # 精确匹配整个单元格内容 - dws sheet find --node --sheet-id --find "完成" --match-entire-cell - - # 搜索公式文本 - dws sheet find --node --sheet-id --find "SUM" --match-formula -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --find string 搜索文本 (必填) - --range string 搜索范围,A1 表示法 (如 A1:D10) - --match-case 区分大小写 (默认 true) - --match-entire-cell 精确匹配整个单元格内容 - --use-regexp 启用正则表达式搜索 - --match-formula 搜索公式文本而非显示值 - --include-hidden 包含隐藏单元格 -``` - -### 在工作表末尾追加数据 -``` -Usage: - dws sheet append [flags] -Example: - dws sheet append --node --sheet-id --values '[["张三","销售部",50000]]' - dws sheet append --node --sheet-id "Sheet1" \ - --values '[["李四","市场部",38000],["王五","销售部",62000]]' -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --values string 追加数据,二维 JSON 数组 (必填) -``` - -`--values` 为二维 JSON 数组,外层每个元素代表一行,内层每个元素代表一个单元格值。 -追加的数据列数应与工作表已有数据的列数保持一致。 - -### 将 CSV 数据写入指定位置 -``` -Usage: - dws sheet csv-put [flags] -Example: - # 内联多行 CSV:必须用 $'...' 让 \n 变成真换行;普通单引号 '...\n...' 里的 \n 是字面量,会写成一行含字面 \n 的错误数据 - dws sheet csv-put --node --sheet-id --start-cell A1 \ - --csv $'name,score\nAlice,95\nBob,87' - - dws sheet csv-put --node --sheet-id --start-cell A1 \ - --csv "=1+1,'=1+1" - - # 多行数据推荐用 @文件,最稳妥 - dws sheet csv-put --node --sheet-id --start-cell B2 \ - --csv @data.csv --allow-overwrite - - cat data.csv | dws sheet csv-put --node --sheet-id \ - --start-cell A1 --csv - - - dws sheet csv-put --node --sheet-id --start-cell A1 \ - --csv @data.csv --dry-run -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --csv string CSV 文本、@文件路径 或 - 表示 stdin (必填) - --start-cell string 起始单元格,A1 表示法 (必填) - --allow-overwrite 允许覆盖已有数据 (默认 false) -``` - -将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。 -- 写入值和公式,不支持样式/批注。字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号(例如 `'=1+1`) -- 数字/日期/百分数由表格引擎自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期) -- 自动扩容行列:CSV 数据超出当前工作表维度时自动追加行/列 -- 目标区域如含合并单元格,合并将被打散,值正常写入 -- `--allow-overwrite` 默认 false,目标区域有数据时需显式传 `--allow-overwrite` 才能覆盖 -- `--csv` 支持三种输入:直接传文本、`@filepath` 从本地文件读取、`-` 从 stdin 管道读取 -- CSV 文本上限 2M 字符,单元格总数上限 30000 -- 特殊字符处理:CLI 会自动过滤 `\r`(Windows 换行符)和 BOM(UTF-8 文件头标记),Excel/Windows 导出的 CSV 可直接使用;如 CSV 数据中含零宽字符(U+200B 等)或 Bidi 控制符,CLI 会拒绝并报错 - -### 在指定位置插入行或列 -``` -Usage: - dws sheet insert-dimension [flags] -Example: - # 在第 3 行之前插入 2 行 - dws sheet insert-dimension --node --sheet-id --dimension ROWS --position "3" --length 2 - - # 在 A 列之前插入 1 列 - dws sheet insert-dimension --node --sheet-id --dimension COLUMNS --position "A" --length 1 - - # 使用工作表前缀(忽略 --sheet-id) - dws sheet insert-dimension --node --sheet-id --dimension ROWS --position "Sheet1!3" --length 5 - - # 在 AB 列之前插入 3 列 - dws sheet insert-dimension --node --sheet-id --dimension COLUMNS --position "AB" --length 3 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --dimension string 插入维度: ROWS 或 COLUMNS (必填) - --position string 插入位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A" - --length string 插入数量,正整数 (必填),最大 5000 -``` - -在钉钉表格指定工作表的指定位置之前插入若干空行或空列。 -`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。 -支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 -若需要在末尾追加行/列,请使用 `append` 命令。 - -### 删除指定位置的行或列 - -> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 - -``` -Usage: - dws sheet delete-dimension [flags] -Example: - # 从第 3 行开始删除 2 行 - dws sheet delete-dimension --node --sheet-id --dimension ROWS --position "3" --length 2 - - # 从 A 列开始删除 1 列 - dws sheet delete-dimension --node --sheet-id --dimension COLUMNS --position "A" --length 1 - - # 使用工作表前缀(忽略 --sheet-id) - dws sheet delete-dimension --node --sheet-id --dimension ROWS --position "Sheet1!3" --length 5 - - # 从 AB 列开始删除 3 列 - dws sheet delete-dimension --node --sheet-id --dimension COLUMNS --position "AB" --length 3 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --dimension string 删除维度: ROWS 或 COLUMNS (必填) - --position string 删除起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A" - --length string 删除数量,正整数 (必填),最大 5000 -``` - -在钉钉表格指定工作表中,从指定位置起删除若干连续的行或列。 -`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。 -支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 -删除后后续的行/列会向前移动填补空位;若需要仅清空内容但保留行/列占位,请使用 `range clear`(整片区域清除,比逐格写空更简洁)。 - -### 更新指定范围行/列属性 -``` -Usage: - dws sheet update-dimension [flags] -Example: - # 隐藏第 3~4 行 - dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "3" --length 2 --hidden - - # 显示 A~B 列 - dws sheet update-dimension --node --sheet-id --dimension COLUMNS --start-index "A" --length 2 --hidden=false - - # 设置第 1~5 行行高为 40px - dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "1" --length 5 --pixel-size 40 - - # 设置 C 列列宽为 200px 并隐藏 - dws sheet update-dimension --node --sheet-id --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden - - # 使用工作表前缀(忽略 --sheet-id) - dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --dimension string 更新维度: ROWS 或 COLUMNS (必填) - --start-index string 起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A" - --length string 更新数量,正整数 (必填),最大 5000 - --hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一 - --pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一 -``` - -批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。 -`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。 -支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 -`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 -`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。 - -### 合并单元格 -``` -Usage: - dws sheet merge-cells [flags] -Example: - # 合并所有单元格(默认) - dws sheet merge-cells --node --sheet-id --range "A1:B3" - - # 按行合并 - dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeRows - - # 按列合并 - dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeColumns - - # 使用带工作表前缀的范围(忽略 --sheet-id) - dws sheet merge-cells --node --sheet-id --range "Sheet1!A1:B3" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格区域地址,如 A1:B3 (必填) - --merge-type string 合并方式: mergeAll(默认)/mergeRows/mergeColumns -``` - -支持三种合并方式: -- `mergeAll`(默认):合并所有单元格,将选定区域内的所有单元格合并成一个 -- `mergeRows`:按行合并,在选定区域内将同一行相邻的单元格合并 -- `mergeColumns`:按列合并,在选定区域内将同一列相邻的单元格合并 - -注意:合并时只保留左上角单元格的值,其他单元格的值会被丢弃。 -`--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时将优先使用前缀解析出的工作表,忽略 `--sheet-id`。 - -### 上传附件到表格 -``` -Usage: - dws sheet media-upload [flags] -Example: - dws sheet media-upload --node --file ./report.pdf - dws sheet media-upload --node --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream -Flags: - --node string 目标表格文档的标识,支持传入 URL 或 ID (必填) - --file string 本地文件路径 (必填) - --name string 附件显示名称 (默认使用文件名) - --mime-type string 文件 MIME 类型 (默认根据扩展名推断) -``` - -`--format json` 输出规整 JSON:`{success, resourceId, resourceUrl, fileName, fileSize}`。`resourceUrl` 可用于 `create-float-image` 的 `--src`。 - -### 上传图片并写入表格单元格 -``` -Usage: - dws sheet write-image [flags] -Example: - dws sheet write-image --node --sheet-id --range A1:A1 --file ./chart.png - dws sheet write-image --node --sheet-id --range B2:B2 --file ./logo.png --width 200 --height 100 -Flags: - --node string 目标表格文档的标识,支持传入 URL 或 ID (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格区域地址,如 A1:A1 (必填) - --file string 本地图片文件路径 (必填) - --name string 图片显示名称 (默认使用文件名) - --mime-type string 文件 MIME 类型 (默认根据扩展名推断) - --width int 图片显示宽度 (可选) - --height int 图片显示高度 (可选) -``` - -### 创建浮动图片 -``` -Usage: - dws sheet create-float-image [flags] -Example: - # 先上传图片获取 resourceUrl - dws sheet media-upload --node --file ./chart.png - # 输出: resourceUrl: /core/api/resources/img/xxxx... - - # 再创建浮动图片 - dws sheet create-float-image --node --sheet-id \ - --src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300 - - # 带偏移量 - dws sheet create-float-image --node --sheet-id \ - --src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填) - --range string 锚点单元格,A1 表示法,如 A1、B3 (必填) - --width int 图片宽度,像素,正整数 (必填) - --height int 图片高度,像素,正整数 (必填) - --offset-x int 水平偏移量,像素 (默认 0) - --offset-y int 垂直偏移量,像素 (默认 0) -``` - -浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL -- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) -- `--width` / `--height` 为必填,单位像素,必须为正整数 -- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数 - -### 获取浮动图片详情 -``` -Usage: - dws sheet get-float-image [flags] -Example: - dws sheet get-float-image --node --sheet-id --float-image-id -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --float-image-id string 浮动图片 ID (必填) -``` - -获取单个浮动图片的详细信息,包括 ID、图片资源路径、锚点位置、尺寸和偏移量。 -`--float-image-id` 可通过 `list-float-images` 获取。 - -### 列出工作表所有浮动图片 -``` -Usage: - dws sheet list-float-images [flags] -Example: - dws sheet list-float-images --node --sheet-id -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) -``` - -列出指定工作表中所有浮动图片,返回 `floatImages` 数组和 `totalCount`。 - -### 更新浮动图片属性 -``` -Usage: - dws sheet update-float-image [flags] -Example: - # 移动浮动图片到新位置 - dws sheet update-float-image --node --sheet-id --float-image-id --range C5 - - # 调整尺寸 - dws sheet update-float-image --node --sheet-id --float-image-id --width 600 --height 400 - - # 替换图片(需先 media-upload 新图片获取 resourceUrl) - dws sheet update-float-image --node --sheet-id --float-image-id \ - --src "/core/api/resources/img/xxxx..." -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --float-image-id string 浮动图片 ID (必填) - --src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl - --range string 新的锚点单元格,A1 表示法 - --width int 新的图片宽度,像素 - --height int 新的图片高度,像素 - --offset-x int 新的水平偏移量,像素 - --offset-y int 新的垂直偏移量,像素 -``` - -更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。 -`--float-image-id` 可通过 `list-float-images` 获取。 - -### 删除浮动图片 -``` -Usage: - dws sheet delete-float-image [flags] -Example: - dws sheet delete-float-image --node --sheet-id --float-image-id -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --float-image-id string 浮动图片 ID (必填) -``` - -删除指定的浮动图片,操作不可恢复。`--float-image-id` 可通过 `list-float-images` 获取。 - -### 全局查找替换 -``` -Usage: - dws sheet replace [flags] -Example: - dws sheet replace --node --sheet-id --find "旧文本" --replacement "新文本" - dws sheet replace --node --sheet-id --find "待处理" --replacement "已完成" --match-entire-cell - dws sheet replace --node --sheet-id --find "\\d{4}" --replacement "****" --use-regexp - dws sheet replace --node --sheet-id --find "旧" --replacement "新" --range "A1:D100" - dws sheet replace --node --sheet-id --find "临时" --replacement "" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --find string 查找文本 (必填) - --replacement string 替换文本 (必填,可为空字符串表示删除) - --range string 替换范围,A1 表示法 (如 A1:D100) - --match-case 区分大小写 (默认 false) - --match-entire-cell 完整单元格匹配 - --use-regexp 启用正则表达式匹配 - --include-hidden 包含隐藏行/列 -``` - -返回被替换的单元格数量。`--replacement` 可以为空字符串,表示删除匹配内容。 - -### 移动行或列 -``` -Usage: - dws sheet move-dimension [flags] -Example: - # 将第 2 行移动到第 5 行的位置(索引从 0 开始) - dws sheet move-dimension --node --sheet-id \ - --dimension ROWS --start-index 1 --end-index 1 --destination-index 4 - - # 将第 2~4 行移动到第 1 行之前 - dws sheet move-dimension --node --sheet-id \ - --dimension ROWS --start-index 1 --end-index 3 --destination-index 0 - - # 将 B~C 列移动到 E 列的位置 - dws sheet move-dimension --node --sheet-id \ - --dimension COLUMNS --start-index 1 --end-index 2 --destination-index 4 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --dimension string 维度类型: ROWS 或 COLUMNS (必填) - --start-index int 源起始索引,0-based (必填) - --end-index int 源结束索引,0-based,包含 (必填) - --destination-index int 目标位置索引,0-based (必填) -``` - -索引均为 0-based(第 1 行/列的索引为 0)。destination-index 不能在 [start-index, end-index] 范围内。 - -**destination-index 计算规则:** -destination-index 是目标位置的 0-based 索引,即移动到第 n 行/列则传 n-1: -- 通用公式:`destination-index = 目标行号(1-based) - 1` -- 例如:将第 2 行移到第 5 行位置 → `destination-index = 5 - 1 = 4`,即 `start-index=1, end-index=1, destination-index=4` -- 例如:将第 4 行移到第 1 行(最前面)→ `destination-index = 1 - 1 = 0`,即 `start-index=3, end-index=3, destination-index=0` - -### 追加空行或空列 -``` -Usage: - dws sheet add-dimension [flags] -Example: - dws sheet add-dimension --node --sheet-id --dimension ROWS --length 5 - dws sheet add-dimension --node --sheet-id --dimension COLUMNS --length 3 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --dimension string 维度类型: ROWS 或 COLUMNS (必填) - --length int 追加数量,正整数,最多 5000 (必填) -``` - -在工作表末尾追加指定数量的空行或空列。 - -### 创建行或列分组 -``` -Usage: - dws sheet group-dimension [flags] -Example: - dws sheet group-dimension --node --sheet-id --range "3:7" - dws sheet group-dimension --node --sheet-id --range "C:F" --group-state fold - dws sheet group-dimension --node --sheet-id --range "Sheet1!3:7" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) - --group-state string 创建后的分组状态: expand 或 fold,默认 expand -``` - -在工作表中为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持普通单元格矩形范围。 - -### 取消行或列分组 -``` -Usage: - dws sheet ungroup-dimension [flags] -Example: - dws sheet ungroup-dimension --node --sheet-id --range "3:7" - dws sheet ungroup-dimension --node --sheet-id --range "C:F" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) -``` - -取消指定连续行或连续列的分组。 - -### 取消合并单元格 -``` -Usage: - dws sheet unmerge-cells [flags] -Example: - dws sheet unmerge-cells --node --sheet-id --range "A1:D5" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 取消合并的范围,A1 表示法 (必填) -``` - -取消指定范围内所有合并的单元格,恢复为独立单元格。 - -### 设置下拉列表 -``` -Usage: - dws sheet set-dropdown [flags] -Example: - # 设置单选下拉列表 - dws sheet set-dropdown --node --sheet-id --range "A2:A100" \ - --options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]' - - # 设置带颜色的多选下拉列表 - dws sheet set-dropdown --node --sheet-id --range "B2:B50" \ - --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ - --multi-select -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) - --options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]' - --multi-select 是否允许多选(默认单选) -``` - -在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。 -- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。 -- **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。 -- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。 - -### 获取下拉列表配置 -``` -Usage: - dws sheet get-dropdown [flags] -Example: - dws sheet get-dropdown --node --sheet-id --range "A2:A100" - dws sheet get-dropdown --node --sheet-id --range "A1" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 查询范围,A1 表示法,如 A1:A100 (必填) -``` - -查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。 -- **用途**:查看单元格已设置的下拉列表选项和配置。 -- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `multipleValues` 和 `colorValueMap`)。**判空以 `hasDropdown` 为准**:无下拉列表时 `hasDropdown` 为 false,但 `dataValidations` 里仍会带 1 个全 null 的幽灵条目,不要用数组长度判空。 -- **已知限制**:`options.multipleValues` 服务端恒返回 `null`(即使该下拉是用 `--multi-select` 建的),且不返回 `enableMultiSelect` 字段——**`get-dropdown` 读回无法判断下拉是否多选**。要判断是否多选,改用 `range read`:其 `cells[].dataValidation.enableMultiSelect` 字段准确(实测有效)。 - -### 删除下拉列表 -``` -Usage: - dws sheet delete-dropdown [flags] -Example: - dws sheet delete-dropdown --node --sheet-id --range "A2:A100" - dws sheet delete-dropdown --node --sheet-id --range "B1:D10" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 要删除下拉列表的范围,A1 表示法 (必填) -``` - -删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。 -- **用途**:移除不再需要的下拉列表约束。 -- **注意**:已填写的单元格值不会被清除;目标范围不存在下拉列表时操作仍返回成功。 - -### 获取筛选信息 -``` -Usage: - dws sheet filter get [flags] -Example: - dws sheet filter get --node --sheet-id - dws sheet filter get --node "https://alidocs.dingtalk.com/i/nodes/" --sheet-id "Sheet1" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) -``` - -获取指定工作表的全局筛选信息,返回筛选范围和各列的筛选条件详情。 -- **用途**:查看当前工作表上是否存在全局筛选及其配置。 -- **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。 -- **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。 -- **返回**:`range`(筛选范围,A1 表示法)、`id`(筛选 ID)和 `criteria`(各列条件对象,key 为列偏移量;无条件时为 `{}`)。如果未设置筛选,返回筛选信息为空。 - -### 创建筛选 -``` -Usage: - dws sheet filter create [flags] -Example: - # 创建筛选框架(不设条件) - dws sheet filter create --node --sheet-id --range "A1:E100" - - # 创建筛选并同时设置条件(按值筛选) - dws sheet filter create --node --sheet-id --range "A1:E100" --criteria '[{"column":1,"filterType":"values","visibleValues":["北京","上海"]}]' - - # 创建筛选并设置条件筛选 - dws sheet filter create --node --sheet-id --range "A1:E100" --criteria '[{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]' -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 筛选范围,A1 表示法,须包含表头行 (必填) - --criteria string 筛选条件 JSON 数组 (可选) -``` - -在工作表中创建全局筛选。 -- **用途**:为工作表建立筛选器,使数据可按条件过滤展示。 -- **约束**:每个工作表只能有一个全局筛选,已存在时会报错。应先 `filter get` 确认不存在后再创建。 -- **range 规范**:必须包含表头行(如 `A1:E100`),不能只包含数据行。 -- **criteria 格式**:JSON 数组,每个元素含 `column`(列偏移量,从 0 开始)和筛选条件字段。不传则仅创建空筛选框架,后续可通过 `filter update` 设置条件。 - -### 删除筛选 - -> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 - -``` -Usage: - dws sheet filter delete [flags] -Example: - dws sheet filter delete --node --sheet-id --yes -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) -``` - -删除工作表的全局筛选。 -- **用途**:移除筛选器,所有被隐藏的行将重新显示。 -- **不可逆**:删除后所有筛选条件丢失,需重新创建。 -- **前置**:工作表没有筛选时调用会报错,应先 `filter get` 确认存在。 - -### 批量更新筛选条件 -``` -Usage: - dws sheet filter update [flags] -Example: - # 同时设置多列的筛选条件 - dws sheet filter update --node --sheet-id --criteria '[{"column":0,"filterType":"values","visibleValues":["已完成","进行中"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50"}]}]' - - # 按颜色筛选 - dws sheet filter update --node --sheet-id --criteria '[{"column":1,"filterType":"color","backgroundColor":"#FF0000"}]' -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --criteria string 筛选条件 JSON 数组 (必填) -``` - -批量更新筛选条件,可同时设置多列的筛选条件。 -- **用途**:一次性设置或替换多列的筛选条件。 -- **前置**:工作表必须已创建筛选(通过 `filter create`)。 -- **覆盖式**:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置。 -- **criteria 格式**:JSON 数组,支持三种 `filterType`: - - `values`:按值筛选,指定 `visibleValues` 数组 - - `condition`:按条件筛选,指定 `conditions` 数组(最多 2 个)和可选的 `conditionOperator`(`and`/`or`) - - `color`:按颜色筛选,指定 `backgroundColor` 或 `fontColor`(二选一) - -### 清除单列筛选条件 -``` -Usage: - dws sheet filter clear-criteria [flags] -Example: - # 清除第 2 列(B 列)的筛选条件 - dws sheet filter clear-criteria --node --sheet-id --column 1 - - # 清除第 1 列(A 列)的筛选条件 - dws sheet filter clear-criteria --node --sheet-id --column 0 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --column number 列偏移量,从 0 开始 (必填) -``` - -清除筛选中某一列的筛选条件。 -- **用途**:移除某列的筛选条件,该列不再参与筛选计算。 -- **区分**:仅清除指定列的条件,不删除整个筛选。如需删除整个筛选,使用 `filter delete`。 -- **幂等**:指定列没有设置筛选条件时调用不会报错。 - -### 筛选排序 -``` -Usage: - dws sheet filter sort [flags] -Example: - # 按第 1 列(A 列)升序排序 - dws sheet filter sort --node --sheet-id --column 0 --ascending - - # 按第 3 列(C 列)降序排序 - dws sheet filter sort --node --sheet-id --column 2 --ascending=false -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --column number 排序列偏移量,从 0 开始 (必填) - --ascending 是否升序,默认 true (可选) -``` - -对筛选范围内的数据按指定列排序。 -- **用途**:对数据行按某一列的值进行升序或降序排列。 -- **前置**:工作表必须已创建筛选(通过 `filter create`)。 -- **注意**:排序会实际改变工作表中数据行的物理顺序,不可撤销。 -- **column**:列偏移量从 0 开始,相对于筛选范围首列。 - -### 获取所有筛选视图 -``` -Usage: - dws sheet filter-view list [flags] -Example: - dws sheet filter-view list --node --sheet-id - dws sheet filter-view list --node "https://alidocs.dingtalk.com/i/nodes/" --sheet-id "Sheet1" -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) -``` - -获取指定工作表的所有筛选视图列表,返回每个筛选视图的 ID、名称和范围信息。 -- **用途**:查看当前工作表上已创建的所有筛选视图,获取视图 ID、名称和范围。 -- **场景**:在对筛选视图进行 update / delete / update-criteria 等操作前,先用 list 获取可用的 filterViewId。 -- **区分**:筛选视图(filter-view)是个人化的数据过滤方式,与全局筛选不同。每个用户可以创建自己的筛选视图,互不影响原始数据。如果没有筛选视图,返回空列表。 - -### 创建筛选视图 -``` -Usage: - dws sheet filter-view create [flags] -Example: - # 创建不带筛选条件的筛选视图 - dws sheet filter-view create --node --sheet-id --name "我的视图" --range "A1:E10" - - # 创建带按值筛选条件的筛选视图 - dws sheet filter-view create --node --sheet-id --name "销售筛选" --range "A1:E10" \ - --criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]}]' - - # 创建带按条件筛选的筛选视图(大于等于 200000) - dws sheet filter-view create --node --sheet-id --name "高预算" --range "A1:C10" \ - --criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}]' -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --name string 筛选视图名称 (必填) - --range string 筛选视图范围,A1 表示法,如 A1:E10 (必填) - --criteria string 筛选条件,JSON 数组 (可选) -``` - -在指定工作表中创建一个筛选视图。 -- **用途**:为指定数据区域创建一个可命名的个人化筛选视图,可选同时设置筛选条件。 -- **场景**:用户需要针对某个数据区域建立固定的筛选视角(如"高绩效员工""研发部数据"),方便反复查看。 -- **区分**:与全局筛选不同,筛选视图是个人化的,不影响其他用户看到的数据。如果只需创建视图不设条件,后续可通过 `update-criteria` 单独设置;如果要一步到位,可通过 `--criteria` 在创建时直接设置。 -`--criteria` 为 JSON 数组,每个元素包含 `column`(列偏移量,从 0 开始)和筛选条件字段。支持三种筛选类型: -- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表 -- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符(kebab-case):`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或) -- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一 - -### 更新筛选视图属性 -``` -Usage: - dws sheet filter-view update [flags] -Example: - # 更新筛选视图名称 - dws sheet filter-view update --node --sheet-id --filter-view-id --name "新名称" - - # 更新筛选视图范围 - dws sheet filter-view update --node --sheet-id --filter-view-id --range "A1:F20" - - # 更新筛选条件 - dws sheet filter-view update --node --sheet-id --filter-view-id \ - --criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]' -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) - --name string 筛选视图新名称 - --range string 筛选视图新范围,A1 表示法 - --criteria string 筛选条件,JSON 数组 -``` - -更新筛选视图的名称、范围和/或筛选条件,`--name`、`--range`、`--criteria` 至少传入一个。 -- **用途**:修改已有筛选视图的名称、数据范围或筛选条件。 -- **场景**:数据区域扩展后需要扩大筛选视图范围,或重命名视图,或通过 `--criteria` 一次性批量更新多列筛选条件。 -- **区分**:`update` 可同时修改名称、范围和条件,适合批量更新;`update-criteria` 只能设置单列条件,适合精确控制某一列的筛选逻辑。`--criteria` 指定列的条件会被替换,未指定的列保持不变。 - -`--criteria` 为 JSON 数组,格式与 `filter-view create` 的 `--criteria` 相同,支持的筛选类型和操作符参见「创建筛选视图」说明。 - -### 删除筛选视图 - -> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。 - -``` -Usage: - dws sheet filter-view delete [flags] -Example: - dws sheet filter-view delete --node --sheet-id --filter-view-id -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) -``` - -删除指定的筛选视图。 -- **用途**:永久删除一个不再需要的筛选视图及其所有筛选条件。 -- **场景**:筛选视图已过时或不再需要时,清理无用的视图。 -- **区分**:`delete` 删除整个筛选视图(包括所有列的条件),操作不可恢复;`delete-criteria` 只删除某一列的筛选条件,视图本身保留。此操作不影响全局筛选或其他筛选视图,也不影响原始数据。 - -### 更新筛选视图列条件 -``` -Usage: - dws sheet filter-view update-criteria [flags] -Example: - # 按值筛选:只显示"销售部"和"市场部" - dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' - - # 按条件筛选:大于 100 - dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' - - # 按条件筛选:大于等于 200000 - dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}' - - # 按条件筛选:小于 100 - dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"less","value":"100"}]}' - - # 多条件筛选:大于等于 60 且 小于等于 90 - dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 2 --filter-criteria '{"filterType":"condition","conditionOperator":"and","conditions":[{"operator":"greater-equal","value":"60"},{"operator":"less-equal","value":"90"}]}' - - # 按颜色筛选:背景色为红色 - dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 1 --filter-criteria '{"filterType":"color","backgroundColor":"#FF0000"}' -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) - --column int 列偏移量,从 0 开始 (必填) - --filter-criteria string 筛选条件,JSON 对象 (必填) -``` - -更新筛选视图中某一列的筛选条件。 -- **用途**:为筛选视图的指定列创建或更新筛选条件,控制该列哪些数据行可见。 -- **场景**:只显示某些特定值的行(如"只看研发部")→ `filterType: values`;按数值条件筛选(如"绩效 ≥ 85")→ `filterType: condition` + `operator: greater-equal`;按文本条件筛选(如"名称包含关键字")→ `filterType: condition` + `operator: contains`。 -- **区分**:`update-criteria` 精确控制单列条件,适合逐列设置不同的筛选逻辑;`filter-view update --criteria` 可以批量更新多列条件;`delete-criteria` 是 `update-criteria` 的逆操作,删除指定列的条件。 - -`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。 -例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列,`--column 1` 代表 C 列。 - -`--filter-criteria` 为 JSON 对象,支持三种筛选类型: -- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表 -- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符:`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或) -- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一 - -### 删除筛选视图列条件 -``` -Usage: - dws sheet filter-view delete-criteria [flags] -Example: - # 删除第 1 列(A 列)的筛选条件 - dws sheet filter-view delete-criteria --node --sheet-id --filter-view-id --column 0 - - # 删除第 3 列(C 列)的筛选条件 - dws sheet filter-view delete-criteria --node --sheet-id --filter-view-id --column 2 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) - --column int 列偏移量,从 0 开始 (必填) -``` - -清除筛选视图中指定列的筛选条件。 -- **用途**:移除筛选视图中指定列的筛选条件,使该列不再参与过滤。 -- **场景**:之前通过 `update-criteria` 设置了某列的筛选条件,现在需要取消该列的筛选以显示全部数据。 -- **区分**:`delete-criteria` 只清除指定列的条件,筛选视图本身和其他列的条件保持不变;`delete` 会删除整个筛选视图。如果指定列没有设置筛选条件,调用此命令不会报错(幂等操作)。 - -### 获取单个筛选视图详情 -``` -Usage: - dws sheet filter-view info [flags] -Example: - # 查看指定筛选视图的详情 - dws sheet filter-view info --node --sheet-id --filter-view-id -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) -``` - -获取指定筛选视图的完整信息,包括 ID、名称、范围和筛选条件。 -- **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。 -- **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。 -- **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。 -- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。 - -### 列出筛选视图所有列条件 -``` -Usage: - dws sheet filter-view list-criteria [flags] -Example: - # 列出筛选视图的所有条件 - dws sheet filter-view list-criteria --node --sheet-id --filter-view-id -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) -``` - -列出指定筛选视图中已设置的所有列筛选条件。 -- **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。 -- **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。 -- **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。 -- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。 - -### 获取单列筛选条件 -``` -Usage: - dws sheet filter-view get-criteria [flags] -Example: - # 查看第 1 列(偏移量 0)的筛选条件 - dws sheet filter-view get-criteria --node --sheet-id --filter-view-id --column 0 - - # 查看第 3 列(偏移量 2)的筛选条件 - dws sheet filter-view get-criteria --node --sheet-id --filter-view-id --column 2 -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --filter-view-id string 筛选视图 ID (必填) - --column int 列偏移量,从 0 开始 (必填) -``` - -获取指定筛选视图中某一列的筛选条件详情。 -- **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。 -- **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。 -- **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。 -- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。 - -### 导出表格为 xlsx(异步任务一站式) -``` -Usage: - dws sheet export [flags] # 一站式:提交 → 轮询 → 可选下载 -Example: - # 仅导出,返回 downloadUrl(链接有时效性,请尽快下载) - dws sheet export --node - dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" - - # 导出并自动下载为本地文件 - dws sheet export --node --output ./report.xlsx - - # --output 为目录时,自动按下载链接中的文件名保存 - dws sheet export --node --output ./ - -Flags: - --node string 表格文档 ID 或 URL (必填) - --output string 本地保存路径(可选,支持文件路径或目录) -``` - -将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 - -**内部流程**: -1. 调 `submit_export_job` 获取 `jobId` -2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 -3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 - -**内置轮询策略(CLI 内实现,无需关心)**: -- 第 1~5 次:每次间隔 2 秒 -- 第 6~10 次:每次间隔 5 秒 -- 第 11~20 次:每次间隔 10 秒 -- 第 21~30 次:每次间隔 15 秒 -- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误 - -**命令返回**(`--format json`,默认):输出规整 JSON `{success, jobId, downloadUrl[, outputPath]}`。轮询进度以 `[INFO] [N/30] 状态: ...` 打到 stderr,不污染 stdout 的 JSON。 -- `--output` 未指定:JSON 含 `jobId` + `downloadUrl`(链接有时效性,请尽快下载) -- `--output` 指定为文件路径:下载到该路径,JSON 额外含 `outputPath` -- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录,JSON 含 `outputPath` - -**失败处理(命令内部已处理,Agent 仅需转述)**: -- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 -- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 - -**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 - -### 表格模板(list / search / apply) -``` -Usage: - dws sheet template list [flags] # 列出可用模板(--cursor / --limit 分页) - dws sheet template search [flags] # 按关键词搜索模板 - dws sheet template apply [flags] # 应用模板创建新表格文档 - -template search Flags: - --query string 搜索关键词 (必填) - --source string 模板来源: MY(我的模版,默认) / PUBLIC(公开模版) - --cursor string 分页游标 - --limit int 返回数量上限 - -template apply Flags: - --template-id string 模板 ID (必填,来自 list/search) - --name string 新表格文档名称 (可选) - --folder string 目标文件夹 ID (可选,UUID 或 URL) - --workspace string 知识库 ID (可选) -``` - -- 用户说"用模板建表 / 有哪些模板 / 找个 XX 模板"时走 template 系列 -- 典型链路:`template search --query "..."` 或 `template list` 拿到 `templateId` → `template apply --template-id --name "..."` 创建新表格,返回新文档的 `nodeId` - -## 核心工作流 +## 导入本地表格 +用户说"导入Excel/把xlsx转为在线表格/上传表格并在线编辑"时,用 `dws sheet import create`(单命令一站式;`sheet import` 保留兼容,详见 [sheet-import](./sheet/sheet-import.md)): ```bash -# ── 工作流 1: 创建表格并写入数据 ── +# 导入本地表格文件为在线电子表格(默认表格名取文件名) +dws sheet import create --file ./data.xlsx -# 1. 创建表格文档 — 提取 nodeId -dws sheet create --name "销售数据" --format json +# 指定目标文件夹与导入后表格名 +dws sheet import create --file ./report.xls --folder-token --name "月度报表" -# 2. 查看工作表列表 — 提取 sheetId -dws sheet list --node --format json +# 导入到指定知识库 +dws sheet import create --file ./data.xls --workspace +``` +- 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格 +- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试** +- 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node` +- **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格 -# 3. 写入表头和数据(每个单元格必须是 object;数字也写成字符串) -dws sheet range update --node --sheet-id --range "A1:C1" \ - --values '[[{"type":"text","text":"姓名"},{"type":"text","text":"部门"},{"type":"text","text":"销售额"}]]' --format json +## nodeId 说明 -dws sheet range update --node --sheet-id --range "A2:C4" \ - --values '[[{"type":"text","text":"张三"},{"type":"text","text":"销售部"},{"type":"text","text":"50000"}],[{"type":"text","text":"李四"},{"type":"text","text":"市场部"},{"type":"text","text":"38000"}],[{"type":"text","text":"王五"},{"type":"text","text":"销售部"},{"type":"text","text":"62000"}]]' --format json +`--node` 同时支持文档 ID、URL、分享链接。`drive list` 返回中必须用 `fileId`(UUID 格式),禁止用 `dentryId`(纯数字)。 -# ── 工作流 2: 读取已有表格数据 ── +## 模板管理 -# 1. 获取工作表列表 -dws sheet list --node --format json +当用户说“用模板创建表格”、“搜索表格模板”、“有哪些表格模板”时使用。 -# 2. 查看工作表详情(行列数、最后非空位置等) -dws sheet info --node --sheet-id --format json +### 获取表格模板列表 -# 3. 读取全部数据 -dws sheet range read --node --sheet-id --format json - -# 4. 读取指定区域 -dws sheet range read --node --sheet-id --range "A1:D10" --format json - -# ── 工作流 3: 多工作表管理 ── - -# 1. 新建工作表 -dws sheet new --node --name "汇总" --format json - -# 2. 在新工作表中写入汇总公式(公式写在 text,以 = 开头) -dws sheet range update --node --sheet-id --range "A1:B1" \ - --values '[[{"type":"text","text":"指标"},{"type":"text","text":"数值"}]]' --format json - -dws sheet range update --node --sheet-id --range "A2:B2" \ - --values '[[{"type":"text","text":"总销售额"},{"type":"text","text":"=SUM(Sheet1!C2:C100)"}]]' --format json - -# ── 工作流 4: 写入数据并设置样式 ── - -# 1. 写入数据 -dws sheet range update --node --sheet-id --range "A1:C3" \ - --values '[[{"type":"text","text":"商品"},{"type":"text","text":"单价"},{"type":"text","text":"数量"}],[{"type":"text","text":"苹果"},{"type":"text","text":"5.5"},{"type":"text","text":"100"}],[{"type":"text","text":"香蕉"},{"type":"text","text":"3.2"},{"type":"text","text":"200"}]]' --format json - -# 2. 设置数字格式(人民币)——批量刷整列走 set-style -dws sheet range set-style --node --sheet-id --range "B2:B3" \ - --number-format "¥#,##0.00" --format json - -# 3. 写入超链接(写在 cell 的 hyperlink 字段) -dws sheet range update --node --sheet-id --range "D1" \ - --values '[[{"type":"text","text":"详情","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]' --format json - -# ── 工作流 5: 追加数据 ── - -# 1. 获取工作表列表 -dws sheet list --node --format json - -# 2. 查看工作表详情(确认列结构) -dws sheet info --node --sheet-id --format json - -# 3. 追加单行数据 -dws sheet append --node --sheet-id \ - --values '[["张三","销售部",50000]]' --format json - -# 4. 追加多行数据 -dws sheet append --node --sheet-id \ - --values '[["李四","市场部",38000],["王五","销售部",62000]]' --format json +``` +Usage: + dws sheet template list [flags] +Example: + dws sheet template list + dws sheet template list --source MY + dws sheet template list --source PUBLIC + dws sheet template list --limit 20 +Flags: + --source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY + --limit int 返回数量上限 (可选) + --cursor string 分页游标 (可选) ``` -```bash -# ── 工作流 6: 插入行或列 ── +### 搜索表格模板 -# 1. 获取工作表列表 -dws sheet list --node --format json - -# 2. 在第 3 行之前插入 2 行 -dws sheet insert-dimension --node --sheet-id \ - --dimension ROWS --position "3" --length 2 --format json - -# 3. 在 A 列之前插入 1 列 -dws sheet insert-dimension --node --sheet-id \ - --dimension COLUMNS --position "A" --length 1 --format json - -# 4. 使用工作表前缀指定位置 -dws sheet insert-dimension --node --sheet-id \ - --dimension ROWS --position "Sheet1!5" --length 3 --format json +``` +Usage: + dws sheet template search [flags] +Example: + dws sheet template search --query "预算" + dws sheet template search --query "排班表" --limit 10 + dws sheet template search --query "财务" --source PUBLIC +Flags: + --query string 搜索关键词 (必填) + --source string 模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY + --limit int 返回数量上限 (可选) + --cursor string 分页游标 (可选) ``` -```bash -# ── 工作流 6b: 删除行或列 ── +### 应用表格模板 -# 1. 获取工作表列表 -dws sheet list --node --format json - -# 2. 从第 3 行开始删除 2 行 -dws sheet delete-dimension --node --sheet-id \ - --dimension ROWS --position "3" --length 2 --format json - -# 3. 从 A 列开始删除 1 列 -dws sheet delete-dimension --node --sheet-id \ - --dimension COLUMNS --position "A" --length 1 --format json - -# 4. 使用工作表前缀指定位置 -dws sheet delete-dimension --node --sheet-id \ - --dimension ROWS --position "Sheet1!5" --length 3 --format json +``` +Usage: + dws sheet template apply [flags] +Example: + dws sheet template apply --template-id TPL_ID --name "月度预算表" + dws sheet template apply --template-id TPL_ID --name "排班表" --folder FOLDER_ID +Flags: + --template-id string 模板 ID (必填,从 template list/search 获取) + --name string 新表格文档名称 (可选) + --folder string 目标文件夹 ID (可选) + --workspace string 知识库 ID (可选) ``` -```bash -# ── 工作流 6c: 更新行/列属性(显隐、行高/列宽) ── +--- -# 1. 获取工作表列表 -dws sheet list --node --format json +## SKILL 摘要(原 dingtalk-sheet/SKILL.md 正文) -# 2. 隐藏第 3~4 行 -dws sheet update-dimension --node --sheet-id \ - --dimension ROWS --start-index "3" --length 2 --hidden --format json + +## Shortcuts(无专用脚本/recipe 时优先) -# 3. 显示 A~B 列 -dws sheet update-dimension --node --sheet-id \ - --dimension COLUMNS --start-index "A" --length 2 --hidden=false --format json +以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "sheet +" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws sheet --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service sheet --format json` 批量发现。 -# 4. 设置第 1~5 行行高为 40px -dws sheet update-dimension --node --sheet-id \ - --dimension ROWS --start-index "1" --length 5 --pixel-size 40 --format json +| Shortcut | 风险 | 适用场景 | +|---|---|---| +| `dws sheet +list-sheets` | read | 获取表格文档中全部工作表列表 | +| `dws sheet +read` | read | 读取工作表指定范围的结构化单元格数据 | + -# 5. 设置 C 列列宽为 200px 并隐藏 -dws sheet update-dimension --node --sheet-id \ - --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden --format json -``` +## 意图表 -```bash -# ── 工作流 7: 搜索表格数据 ── +| 用户说 | 命令 | +|--------|------| +| "创建电子表格" | `dws sheet create --name "<标题>"` | +| "新建工作表" | `dws sheet new --node --name ""` | +| "读取单元格" | `dws sheet range read --node --sheet-id --range A1:B2` | +| "写入单元格" | `dws sheet range update --node --sheet-id --range A1:B2 --values '[[..]]'` | +| "写入超链接" | `dws sheet range update --node --sheet-id --range A1 --values '[[{"type":"text","text":"显示文本","hyperlink":{"type":"path","link":"https://..."}}]]'` | +| "追加一行" | `dws sheet append --node --sheet-id --values '[[..]]'` | +| "查找 / 替换" | `dws sheet find --node --sheet-id --query "<关键词>"` / `dws sheet replace --node --sheet-id --find "<旧值>" --replacement "<新值>"` | +| "精确匹配搜索 / 完全等于" | `dws sheet find --node --sheet-id --query "<关键词>" --match-entire-cell` | +| "搜索公式文本" | `dws sheet find --node --sheet-id --query "<公式片段>" --match-formula` | +| "正则搜索 / 不区分大小写" | `dws sheet find --node --sheet-id --query "" --use-regexp --match-case=false` | +| "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1 --file <图片路径>` | +| "创建浮动图片" | 先 `dws sheet media-upload --node --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node --sheet-id --src "" --range A1 --width <宽> --height <高>` | -# 1. 获取工作表列表 -dws sheet list --node --format json +## URL 与 ID 前置 -# 2. 基本搜索 — 在指定工作表中查找文本 -dws sheet find --node --sheet-id --find "销售额" --format json +- 用户直接粘贴 `alidocs.dingtalk.com` URL 且意图不明确时,先执行 + `dws drive info --node "" --format json` 探测;只有 `extension=axls` + 才继续走 `sheet`。 +- `spreadsheetv2` 链接不要截取短 path segment,必须把完整 URL 原样传给 `--node`。 +- 写入类命令必须先用 `dws sheet list --node --format json` 取得真实 `sheetId`;不要猜 `Sheet1`、`sheet1`、`0`。 +- 所有 sheet 子命令使用 `--node` 参数;不要写 `--node-id`、`--file-id` 或把 JSON 字段名 `id` 当成节点值传入。 -# 3. 在指定范围内搜索 -dws sheet find --node --sheet-id --find "合计" --range "A1:D100" --format json +## 高频硬约束 -# 4. 正则搜索(不区分大小写) -dws sheet find --node --sheet-id --find "^total" --use-regexp --match-case=false --format json +- `sheet create` 后必须从返回结果提取真实 `nodeId` 或文档 URL,后续 `range update/read` 原样传给 `--node`;如果返回里同时有 `nodeId` 和 `url`,优先用 `nodeId`。 +- 写入区域前先 `sheet list --node --format json` 获取真实 `sheetId`;`range update` 成功后默认用独立 `range read` / `csv-get` 回读受影响区域,不能只凭 `success` / `updatedRows` / `updatedCells` / `message` 宣称完成。仅当用户明确要求“不要再读/信任写入返回”时,才跳过回读并说明验证边界。 +- `range update` 的 `--values` 必须是二维 JSON,行列数量与 `--range` 完全一致;批量样式用 `range batch-set-style --batch `。 +- 整格超链接只能用 `range update` 的 cell-level `hyperlink` 字段:`{"type":"text","text":"显示文本","hyperlink":{"type":"path","link":"https://..."}}`。不要走 `doc`,不要用 `--hyperlinks`,不要把整格链接写成 richText 片段链接。 +- 搜索必须用 `sheet find`,不要用 `range read` 后本地过滤;“精确/完全匹配/等于”必须加 `--match-entire-cell`,“搜公式”必须加 `--match-formula`,“正则且不区分大小写”必须同时加 `--use-regexp --match-case=false`。 +- 同一参数错误最多纠正 2 次;若持续 `nodeId 格式不合法`,回到 + `drive info --node ` 或 `sheet create` 返回重新取 ID,不要无意义重试。 -# 5. 精确匹配整个单元格 -dws sheet find --node --sheet-id --find "完成" --match-entire-cell --format json +## 跨产品协作 -# 6. 搜索公式文本 -dws sheet find --node --sheet-id --find "SUM" --match-formula --format json -``` +- 多维表 / 字段类型 → 切到 `dingtalk-aitable` +- 把表数据写进文档 → 切到 `dingtalk-doc` -```bash -# ── 工作流 8: 合并单元格 ── +## 局部意图与 Recipe -# 1. 获取工作表列表 -dws sheet list --node --format json - -# 2. 合并所有单元格(默认 mergeAll) -dws sheet merge-cells --node --sheet-id --range "A1:B3" --format json - -# 3. 按行合并 -dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeRows --format json - -# 4. 按列合并 -dws sheet merge-cells --node --sheet-id --range "A1:C3" --merge-type mergeColumns --format json -``` - -```bash -# ── 工作流 9: 上传附件到表格 ── - -# 1. 基本用法: 上传本地文件到表格 -dws sheet media-upload --node --file ./report.pdf -f json - -# 2. 自定义附件显示名称 (--name 指定上传后在表格中显示的名称) -dws sheet media-upload --node --file ./data.csv --name "销售数据.csv" -f json - -# 3. 指定 MIME 类型 (文件扩展名无法推断时) -dws sheet media-upload --node --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream -f json - -# 4. 完整流程: 创建表格 → 上传附件 -dws sheet create --name "项目资料" -f json -# 提取 nodeId 后: -dws sheet media-upload --node --file ./design.pdf -f json -dws sheet media-upload --node --file ./timeline.xlsx --name "项目时间线.xlsx" -f json - -# ── 工作流 10: 写入图片到表格单元格 ── - -# 1. 基本用法: 写入图片到指定单元格 -dws sheet write-image --node --sheet-id --range A1:A1 --file ./chart.png -f json - -# 2. 指定显示尺寸 -dws sheet write-image --node --sheet-id --range B2:B2 --file ./logo.png --width 200 --height 100 -f json - -# 3. 自定义图片名称 -dws sheet write-image --node --sheet-id --range C3:C3 --file ./photo.jpg --name "产品图.jpg" -f json - -# 4. 完整流程: 创建表格 → 写表头 → 写入图片 -dws sheet create --name "产品目录" -f json -# 提取 nodeId 后: -dws sheet range update --node --sheet-id Sheet1 --range "A1:B1" --values '[[{"type":"text","text":"产品名称"},{"type":"text","text":"产品图片"}]]' -f json -dws sheet range update --node --sheet-id Sheet1 --range "A2:A2" --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json -dws sheet write-image --node --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json -``` - -```bash -# ── 工作流 11: 筛选视图管理 ── - -# 1. 获取工作表列表 -dws sheet list --node -f json - -# 2. 查看已有筛选视图 -dws sheet filter-view list --node --sheet-id -f json - -# 3. 创建筛选视图(不带条件) -dws sheet filter-view create --node --sheet-id \ - --name "我的筛选" --range "A1:E100" -f json - -# 4. 为筛选视图设置列条件(按值筛选) -dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' -f json - -# 5. 为筛选视图设置列条件(按条件筛选) -dws sheet filter-view update-criteria --node --sheet-id --filter-view-id \ - --column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' -f json - -# 6. 更新筛选视图名称和范围 -dws sheet filter-view update --node --sheet-id --filter-view-id \ - --name "销售数据筛选" --range "A1:F200" -f json - -# 7. 清除某列的筛选条件 -dws sheet filter-view delete-criteria --node --sheet-id --filter-view-id \ - --column 0 -f json - -# 8. 删除筛选视图 -dws sheet filter-view delete --node --sheet-id --filter-view-id -f json -``` - -```bash -# ── 工作流 11b: 创建带条件的筛选视图(一步完成) ── - -# 创建筛选视图时直接指定筛选条件 -dws sheet filter-view create --node --sheet-id \ - --name "高销售额视图" --range "A1:E100" \ - --criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50000"}]}]' \ - -f json -``` - -```bash -# ── 工作流 12: 导出表格为 xlsx(单命令一站式)── - -# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl) -dws sheet export --node --format json -# 传入 URL 也可: -# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --format json - -# 场景 B:导出并自动下载为本地文件 -dws sheet export --node --output ./report.xlsx - -# 场景 C:下载到目录,自动按链接推断文件名 -dws sheet export --node --output ./ - -# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 -# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。 -``` - -## 上下文传递表 - -| 操作 | 从返回中提取 | 用于 | -|------|-------------|------| -| `create` | `nodeId` | list / info / new / range read / range update / find 的 --node | -| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | -| `new` | 新工作表的 `sheetId` | range read / range update / find 的 --sheet-id | -| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、判断合并结构(空表时 nonEmptyRange.* 为 null) | -| `find` | `matchedCells` 中的 `a1Notation` | 定位目标单元格,用于 range read / range update | -| `append` | `a1Notation` 追加数据所在范围 | 确认追加位置 | -| `csv-put` | `a1Notation` 实际写入的单元格范围 | 确认写入位置和范围 | -| `insert-dimension` | `a1Notation` 新插入区域范围 | 确认插入位置和范围 | -| `delete-dimension` | `a1Notation` 被删除区域范围 | 确认删除位置和范围 | -| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 | -| `merge-cells` | `a1Notation` 实际被合并的范围、`mergeType` 生效的合并方式 | 确认合并结果 | -| `media-upload` | `resourceId`、`resourceUrl` | 附件已上传到表格;`resourceUrl` 可用于 `create-float-image` 的 `--src` | -| `write-image` | `resourceId` | 图片已写入指定单元格 | -| `create-float-image` | `floatImage`(含 `id`、`src`、`range`、`width`、`height`、`offsetX`、`offsetY`) | `id` 用于后续 get / update / delete 的 `--float-image-id` | -| `get-float-image` | `floatImage`(完整信息) | 查看单个浮动图片详情 | -| `list-float-images` | `floatImages` 数组、`totalCount` | 获取所有浮动图片的 `id`,用于后续操作 | -| `update-float-image` | `floatImage`(更新后的完整信息) | 确认更新结果 | -| `delete-float-image` | `message` | 确认删除完成 | -| `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 | -| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | -| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | -| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 | -| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 | -| `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 | -| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | -| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | -| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 | -| `filter-view list` | `filterViews` 筛选视图列表(含 `id`、`name`、`range`) | 获取 filterViewId 用于 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria | -| `filter-view info` | `id`、`name`、`range`、`criteria` | 查看单个视图完整配置,确认条件是否生效 | -| `filter-view create` | `id` 筛选视图 ID、`name`、`range` | 用于后续 update / delete / update-criteria / delete-criteria 的 --filter-view-id | -| `filter-view update` | `id`、`name`、`range`、`criteria` | 确认更新结果 | -| `filter-view delete` | `id` 被删除的筛选视图 ID | 确认删除完成 | -| `filter-view update-criteria` | `id` 筛选视图 ID | 确认条件设置完成 | -| `filter-view delete-criteria` | `id` 筛选视图 ID | 确认条件清除完成 | -| `filter-view list-criteria` | 所有列条件(按列偏移量为 key 的对象) | 了解当前视图已设置哪些列的条件 | -| `filter-view get-criteria` | 指定列的条件详情(`filterType`、`conditions` 等) | 查看某列的具体筛选规则 | -| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | - -## nodeId 多格式说明 - -所有 `--node` 参数同时支持文档 ID、文档 URL、表格分享链接,系统自动识别。详细格式和提取规则请参见前文「URL 识别与 NODE_ID 提取」章节。 - -> ** 禁止使用 `dentryId`**:`drive list` 返回结果中同时包含 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式,如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)两个字段。sheet 的 `--node` 和 `--folder` 参数**只能使用 `fileId`(UUID 格式)**,不能使用纯数字的 `dentryId`,传入纯数字会导致命令失败。 - -## values 参数格式说明 - -`--values` 为二维 JSON 数组,第一维为行,第二维为列。**每个单元格必须是 object**,不支持裸值 `"文本"` / `100` / `null`: -- 文本: `{"type":"text","text":"文本"}` -- 数字/布尔: 写成字符串 `{"type":"text","text":"100"}`,服务端按内容自动识别为数字/布尔 -- 公式: `{"type":"text","text":"=SUM(B2:B4)"}`(`text` 以 `=` 开头识别为公式) -- 清空单个单元格: `{"type":"text","text":""}`;清空整片区域用 `range clear` -- 跳过某格保留原值: `{}` 空对象 -- 整格样式: `{"type":"text","text":"重要","cellStyles":{"fontWeight":"bold","fontColor":"#FF0000","numberFormat":"@"}}` -- 富文本: `{"type":"richText","texts":[...]}`(子项 text/link/attachment/image,片段样式写在子项 `style`) - -维度必须与 `--range` 范围一致,例如 `--range "A1:B3"` 需要 3 行 2 列的 object 数组。 - -## 超链接写法说明 - -**没有 `--hyperlinks` flag**(旧写法已废弃,传入报 unknown flag)。单元格级超链接写在 `--values` 里 cell object 的 `hyperlink` 字段: -- `{"type":"text","text":"钉钉","hyperlink":{"type":"path","link":"https://dingtalk.com"}}`:写外部链接 -- `hyperlink.type` 可为 `path`(外部链接)/ `sheet`(工作表跳转,link 为工作表名)/ `range`(区域跳转,link 为 `Sheet2!A1`) -- `{"hyperlink":{"type":"none"}}`:清除整格超链接(保留原值) -- 富文本片段内的链接写在 `richText` 子项的 `link` 字段,与整格 `hyperlink` 不同 - -## number-format 常用值 - -适用范围:`number-format` 在 `range set-style` / `range batch-set-style` 中作为 `--number-format` 参数;`range update` 没有 `--number-format` flag,但可在每个 cell object 的 `cellStyles.numberFormat` 里写同样的格式代码。批量刷整片区域优先用 `set-style`。 - -| 格式代码 | 说明 | 示例 | -|----------|------|------| -| `General` | 常规 | 1234.5 | -| `@` | 文本 | 001234 | -| `#,##0` | 整数千分位 | 1,235 | -| `#,##0.00` | 两位小数 | 1,234.50 | -| `0%` | 百分比 | 85% | -| `yyyy/m/d` | 日期 | 2026/3/15 | -| `hh:mm:ss` | 时间 | 14:30:00 | -| `¥#,##0` | 人民币 | ¥1,235 | - -## 注意事项(完整版) - -> 标 ★ 的条目已在前文「关键注意事项」中列出,此处为完整说明。 - -- ★ `--sheet-id` 获取规范(强制):所有涉及 `--sheet-id` 参数的命令(`info` / `new` / `range read` / `range update` / `find` / `append` / `insert-dimension` / `delete-dimension` / `update-dimension` / `move-dimension` / `add-dimension` / `merge-cells` / `unmerge-cells` / `replace` / `write-image` / `set-dropdown` / `get-dropdown` / `delete-dropdown` / `filter-view *` 等),除非用户主动提供了工作表 ID 或工作表名称,否则在 `sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 -- ★ `range update` 单元格对象协议(强制):`--values` 每个单元格必须是 object(`{"type":"text","text":...}` / `{"type":"richText",...}` / `{}` 等),不支持裸值 `"张三"` / `90` / `null`,裸值报「不支持原始值……每个单元格必须是 object」。数字/布尔写成字符串 object,服务端自动识别类型。超链接写在 cell object 的 `hyperlink` 字段,没有 `--hyperlinks` flag -- ★ `range update` 维度校验(强制):`--values` 二维数组的行数与列数必须与 `--range` 完全一致: - - 例如 `--range "A1:C3"` 表示 3 行 × 3 列,`--values` 必须是 3×3 的 object 数组 - - `--range "A1"` 表示 1 行 × 1 列,`--values` 必须是 `[[{...}]]` - - 不改的格用 `{}` 空对象占位补齐;禁止各行列数不一致或与 `--range` 不匹配,否则报错 -- ★ `range update` 清空单元格规范(强制):清空单个单元格用 `{"type":"text","text":""}`(不是裸 `""`,也不是 `null`);清空整片区域用 `range clear`;跳过某格保留原值用 `{}` 空对象 -- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录 -- `list` 返回所有工作表的 ID 和名称,是后续操作的必要前置步骤 -- `info` 不传 `--sheet-id` 时默认返回第一个工作表的详情 -- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 -- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) -- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表 -- `range update` 只有 `--values` 一个内容参数(必填),超链接/样式/数据验证都写进 cell object 内部字段 -- `range update` 职责边界:可写值、超链接、per-cell 样式(`cellStyles`)与数据验证(`dataValidation`)。批量刷整片区域的统一样式或数字格式优先用 `dws sheet range set-style --number-format <格式代码>`;批量多区域走 `dws sheet range batch-set-style --batch `(配置文件用 `numberFormat` 字段) -- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(底层硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,底层超 30000 会直接报错 -- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式 -- `new` 创建工作表时,如名称与已有工作表重复,系统会自动重命名 -- `find` 返回匹配单元格的地址(A1 表示法)和值,无匹配时返回空数组 -- `find` 的 `--match-entire-cell` 用于精确匹配:只返回单元格内容完全等于搜索文本的结果,不会匹配包含该文本的单元格(例如搜索"苹果"时,只匹配"苹果",不匹配"苹果手机""苹果汁"等)。用户说"精确搜索/完全匹配/只搜等于XX的"时必须使用此参数 -- `find` 的 `--match-case` 默认为 true(区分大小写),设为 false 可忽略大小写 -- `find` 的 `--use-regexp` 启用后,`--find` 参数作为正则表达式处理 -- ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确 -- `append` 自动定位到最后一行有数据的位置下方插入,无需手动计算行号 -- `append` 的 `--values` 二维数组中每行的列数必须一致,否则会报错。如果用户提供的数据中各行长度不同,必须先将短行用空字符串 `""` 补齐到与最长行相同的列数后再调用。追加的数据列数也应与工作表已有数据列数保持一致 -- `append` vs `range update`:追加新行用 `append`,修改已有单元格用 `range update` -- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append` -- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` -- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` -- `insert-dimension` 的 `--length` 最大为 5000 -- `delete-dimension` 从指定位置起删除若干连续的行或列,删除后后续行/列向前移动填补空位 -- `delete-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` -- `delete-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` -- `delete-dimension` 的 `--length` 最大为 5000 -- `delete-dimension` 若需仅清空内容但保留行/列占位,请使用 `range clear`(整片区域清除,比逐格写空更简洁) -- `update-dimension` 批量更新连续行/列的显隐状态与行高/列宽 -- `update-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` -- `update-dimension` 的 `--start-index` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` -- `update-dimension` 的 `--length` 最大为 5000 -- `update-dimension` 的 `--hidden` 与 `--pixel-size` 至少必须提供一个 -- `update-dimension` 的 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽 -- `update-dimension` 当同时提供 `--hidden` 与 `--pixel-size` 时,将先应用尺寸再应用显隐,任一失败整体失败 -- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 -- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) -- `merge-cells` 的 `--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时忽略 `--sheet-id` -- `merge-cells` 如果目标区域与其他合并单元格、锁定区域或表格区域存在交集,合并将失败 -- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 -- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 -- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败 -- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 -- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL -- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) -- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数 -- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小 -- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个 -- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete) -- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除 -- `replace` 的 `--find` 不能为空字符串,`--replace` 可以为空字符串(表示删除匹配内容) -- `replace` 的 `--match-case` 默认为 false(不区分大小写),与 `find` 的默认行为不同 -- ★ `replace` vs `range update`:需要批量替换文本时,必须使用 `replace` 命令,禁止用 `range update` 手动重写单元格来实现替换效果。`replace` 支持服务端全局替换,效率更高且会返回替换计数 -- ★ `move-dimension` vs `range update`:需要移动行或列时,必须使用 `move-dimension` 命令,禁止用 `range update` 读取数据后手动重写来模拟移动效果。`move-dimension` 是原子操作,能保留单元格的格式、合并状态等属性 -- `move-dimension` 的索引均为 0-based(第 1 行/列的索引为 0),`endIndex` 包含在移动范围内 -- `move-dimension` 的 `--destination-index` 不能在 [start-index, end-index] 范围内 -- `move-dimension` 的移动跨度(end-index - start-index + 1)不超过 5000 -- `move-dimension` 的 `--destination-index` 是目标位置的 0-based 索引,即移动到第 n 行/列则传 `n - 1`(通用公式:`destination-index = 目标行号(1-based) - 1`) -- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 -- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 -- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 -- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5` -- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand` -- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold` -- `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围 -- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 -- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false -- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 -- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列 -- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)、`id` 和 `criteria`(各列条件对象,无条件时为 `{}`)。无筛选时返回空 -- `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错 -- `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件 -- `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复 -- `filter delete` 工作表没有筛选时调用会报错,应先 `filter get` 确认存在 -- `filter update` 是覆盖式:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置再 patch -- `filter update` 前置:工作表必须已创建筛选 -- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等) -- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选 -- ★ **筛选操作规范**(参照飞书 core-operations): - - 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替 - - 创建/更新筛选后**必须** `filter get` 回读验证配置正确 - - 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作 - - 筛选条件的列索引(`column`)必须与实际数据列精确对应,不要凭猜测填写 - - 筛选不支持正则表达式,传入正则会当成普通文本处理 -- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id` -- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤 -- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}` -- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示 -- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置 -- `filter-view update` 的 `--name`、`--range`、`--criteria` 至少需要传入一个,未指定的字段保持不变 -- `filter-view update` 的 `--criteria` 中指定列的条件会被替换,未指定的列保持不变 -- `filter-view delete` 删除后该视图及其所有筛选条件将被永久移除,不可恢复 -- `filter-view delete` 不影响全局筛选或其他筛选视图 -- `filter-view update-criteria` 的 `--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列 -- `filter-view update-criteria` 设置条件后立即在该筛选视图中生效,仅影响当前视图,不影响全局筛选或其他筛选视图 -- `filter-view update-criteria` 的 `--filter-criteria` 中 `conditions` 最多 2 个条件,多条件之间通过 `conditionOperator` 指定逻辑关系(`and` 或 `or`) -- `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete` -- `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错 -- 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作 -- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` -- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 -- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 -- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 -- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 -- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 -- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) -- `update` 的 `--title`、`--index`、`--hidden`、`--frozen-row-count`、`--frozen-column-count` 至少必须提供一个 -- `update` 的 `--title` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符 -- `update` 的 `--index` 为 0-based 非负整数,0 表示移动到最前面 -- `update` 的 `--hidden` 设为 true 时,至少需要保留一个可见的工作表,不能将所有工作表都隐藏 -- `update` 的 `--frozen-row-count` / `--frozen-column-count` 为非负整数,不能超过工作表的总行数/列数,设为 0 表示取消冻结 -- `update` 当同时提供多个属性时,所有属性将在同一次请求中更新 -- `copy` 复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表 -- `copy` 的 `--title` 可选,不传时系统自动生成名称(通常为"源名称 副本"或类似格式) -- `copy` 的 `--title` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符 -- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值 -- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置 -- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读) -- 工作表与单元格命令仅支持 `axls`;本地 `xlsx` / `xls` 可通过 `sheet import` 转换为新的在线电子表格 -- 遇到未知 `alidocs` URL 时,必须先 probe(`dws doc info --node --format json`)确认 `contentType` 和 `extension`,才能决定是否走 sheet -- 当节点 `extension=xlsx` / `xls` / `xlsm` / `csv`(`contentType≠ALIDOC`)时,必须用 `dws doc download --node --output <路径>` 先下载到本地再处理,禁止调用任何 sheet 子命令(sheet 底层 MCP 工具只识别 axls,调用 xlsx 节点必失败) -- 要把在线表格导出为 xlsx 文件,走 `dws sheet export`;要把本地 xlsx/xls 转为在线表格,走 `dws sheet import`;要读取已上传但未转换的 xlsx 节点,先走 `dws doc download` +- [局部意图消歧](../intent-guide.md)。 diff --git a/skills/mono/references/products/sheet/sheet-batch-operations.md b/skills/mono/references/products/sheet/sheet-batch-operations.md index 4096e71b..8d922bec 100644 --- a/skills/mono/references/products/sheet/sheet-batch-operations.md +++ b/skills/mono/references/products/sheet/sheet-batch-operations.md @@ -5,9 +5,8 @@ `batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束: 1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / 工作表。规划 range 时先确认每个子操作的边界。 -2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地预先计算的预期值对照。 +2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,单元格值用 `range read` 或 `csv-get` 抽样回读受影响区域;合并用裸 `sheet info`,行列分组用 `sheet info --include groups` 回读校验。 3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `batch-update` 补齐,禁止交付半成品。 -4. **统一确认语义**:`range batch-clear` 和 `batch-update` 都可删除内容或结构;先用 `--dry-run` 预览,真实执行必须获得用户确认后追加 `--yes`。 ## 使用场景 @@ -23,17 +22,28 @@ - 原子事务:任一操作失败则整批回滚 - 传 `--continue-on-error` 可切换为宽松模式(失败继续) +用户说"批量写入多个结构化 table/跨 sheet 写入 dataframe/table": +- 不使用 `batch-update`;直接调用 `dws sheet table-put --node --sheets '{"sheets":[...]}'` +- `table-put` 自身支持一次写入多个 sheet spec,写入后用 `table-get` 回读 dtype / format / 数据行数 + +用户说"批量创建行列分组/批量取消行列分组/同时调整尺寸并分组": +- 连续行/列分组 → `group-dimension` +- 取消连续行/列分组 → `ungroup-dimension` +- 分组回读用 `sheet info --include groups` +- `group-dimension` 在 batch 中只适合默认展开分组;需要创建后立即折叠时,用独立 `dws sheet group-dimension --group-state fold` + **何时推荐使用 `batch-update`**: - 需要对**多个**不同区域执行 `merge-cells` / `unmerge-cells` 时(如按分组合并多列相同内容) - 需要对**多个**不同区域执行 `update-dimension` 时(如统一调整多列列宽或多行行高,含 hidden + pixelSize) +- 需要对**多个**连续行/列范围执行 `group-dimension` / `ungroup-dimension` 时(默认展开分组) - 需要先插入行列再写入数据时(`add-dimension` + `range update` / `csv-put`) -- 需要批量创建或取消行列分组时(`group-dimension` / `ungroup-dimension`) - 需要对多个区域执行不同写入操作时(多次 `range update` + `range clear` 等组合) 当同一工具需要对多个区域重复调用时,**推荐**改用 `batch-update` 合并为单次请求——`batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 **不可放进 `--operations` 的操作**(强行写入会被校验拒或行为未定义): -- `range read` / `csv-get`(只读操作,不属于写入) +- `range read` / `csv-get` / `table-get`(读取操作,不在 batch dispatch 表中;结构化 table 回读需在 batch 外单独调用) +- `table-put`(当前 `batch-update` 不支持结构化 table 写入;请直接调用独立 `dws sheet table-put`) - `range sort` / `range move-to`(尚未支持,后续版本补充) - `write-image` / `media-upload` / `create-float-image` / `update-float-image`(需本地上传或依赖前置上传句柄) - `set-style` / `range batch-set-style`(自身已是批量入口,不可再嵌套) @@ -50,9 +60,9 @@ Usage: dws sheet range batch-clear [flags] Example: - dws sheet range batch-clear --node --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]' --yes - dws sheet range batch-clear --node --ranges '["Sheet1!A1:Z1000"]' --type all --yes - dws sheet range batch-clear --node --ranges '["Sheet1!A2:A100","Sheet1!C2:C100"]' --type format --yes + dws sheet range batch-clear --node --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]' + dws sheet range batch-clear --node --ranges '["Sheet1!A1:Z1000"]' --type all + dws sheet range batch-clear --node --ranges '["Sheet1!A2:A100","Sheet1!C2:C100"]' --type format Flags: --node string 表格文档 ID 或 URL (必填) --ranges string 目标区域 JSON 数组,每项带 sheet 前缀 (必填) @@ -72,9 +82,10 @@ Example: {"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}}, {"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}}, {"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}, - {"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}} - ]' --yes - dws sheet batch-update --node --continue-on-error --operations '[...]' --yes + {"toolName":"update-dimension","input":{"sheet-id":"Sheet1","dimension":"ROWS","start-index":"1","length":1,"pixel-size":40}}, + {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}} + ]' + dws sheet batch-update --node --continue-on-error --operations '[...]' Flags: --node string 表格文档 ID 或 URL (必填) --operations string 操作数组 JSON (必填,每项 {toolName, input}) @@ -91,7 +102,8 @@ Notes: - 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁) - `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本 - 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽 - - 真实执行必须获得用户确认后加 --yes;--dry-run 不需要 --yes + - `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension` + - `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put` ``` ### 子操作定位规则 @@ -107,6 +119,8 @@ Notes: ] ``` +`table-put` / `table-get` 不支持放进 `batch-update`。结构化 table 写入请直接调用 `dws sheet table-put`,写入完成后在 batch 外单独调用 `table-get` 回读校验。 + ## 校验与预览 ### Validate 阶段 @@ -121,27 +135,10 @@ CLI 在发送请求前执行以下本地校验(不消耗网络请求): | 每项必须是 object | `operations[N] 不是 object` | `--operations '["string"]'` | | `toolName` 必须是支持的 CLI 命令名 | `unsupported toolName "xxx"` | 传 `"batch-update"`(禁止嵌套)或拼写错误 | | 禁止嵌套 `batch-update` | 同上(`batch-update` 不在 dispatch 表中,自动拦截) | `toolName: "batch-update"` | +| 禁止 `table-put` / `table-get` | `unsupported toolName "table-put"` | 结构化 table 应使用独立 `table-put` | > `input` 内的字段(如 `sheet-id`、`range`、`values` 等)由服务端校验,CLI 不提前拦截——传入空值或缺失必填字段时,请求会到达服务端再返回错误。 -### DryRun 阶段 - -加 `--dry-run` 预览翻译后的完整 MCP 请求,不实际执行任何操作: - -```bash -dws sheet batch-update --node NODE_ID --dry-run --operations '[...]' -``` - -DryRun 输出内容: -- `Tool: batch_update`(最终调用的 MCP 工具名) -- `Arguments:` 完整 JSON(含翻译后的 `operations` 数组,展示每个子操作的 MCP toolName + 参数名 + 参数值) -- 若传了 `--continue-on-error`,输出 JSON 中会包含 `"continueOnError": true` - -DryRun 适用场景: -- 确认 CLI 命令名 → MCP toolName 的翻译是否正确 -- 确认参数名转换(如 `--range` → `rangeAddress`)是否符合预期 -- 检查 `csv-put` 的 `@filepath` 是否被正确解析为文件内容,并确认 `=...` / `'=...` 字段原样进入子操作 - ### --continue-on-error 行为 | 模式 | flag | 子操作失败时 | 返回格式 | @@ -149,7 +146,7 @@ DryRun 适用场景: | 严格事务(默认) | 不传 | 整批回滚到初始状态,后续子操作不执行 | 顶层 `error` 对象 | | 宽松模式 | `--continue-on-error` | 已执行的子操作不回滚,继续执行后续子操作 | `results` 数组 | -**严格事务失败的错误信息**由服务端(flex-table-app)生成,包含以下内容: +**严格事务失败的错误信息**由服务端生成,包含以下内容: - 失败操作索引:`operations[N]`(0-based,即 `--operations` JSON 数组中的下标) - 回滚告知:"All previously executed operations have been rolled back." - 失败原因:原始错误码和错误消息 @@ -177,13 +174,13 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' | `merge-cells` | [sheet-style-format](./sheet-style-format.md) | | `unmerge-cells` | [sheet-style-format](./sheet-style-format.md) | | `update-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | +| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | +| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `range fill` | [sheet-range-operations](./sheet-range-operations.md) | | `range copy-to` | [sheet-range-operations](./sheet-range-operations.md) | | `add-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `delete-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `move-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | -| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | -| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `set-dropdown` | [sheet-dropdown](./sheet-dropdown.md) | | `delete-dropdown` | [sheet-dropdown](./sheet-dropdown.md) | | `csv-put` | [sheet-write-data](./sheet-write-data.md) | @@ -237,32 +234,44 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' ] ``` -### 批量创建行列分组 +### 批量创建/取消行列分组 + +分组属于工作表结构元数据,批次完成后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups`,不要用裸 `sheet info` 或 `range read` 校验。回读项中的 `level` 是 1-based,`collapsed` 表示当前折叠状态。 ```jsonc [ {"toolName":"group-dimension", "input":{"sheet-id":"...","range":"3:7","group-state":"expand"}}, {"toolName":"group-dimension", - "input":{"sheet-id":"...","range":"C:F"}}, - {"toolName":"ungroup-dimension", - "input":{"sheet-id":"...","range":"10:12"}} + "input":{"sheet-id":"...","range":"C:F","group-state":"expand"}} ] ``` +```jsonc +[ + {"toolName":"ungroup-dimension", + "input":{"sheet-id":"...","range":"3:7"}}, + {"toolName":"ungroup-dimension", + "input":{"sheet-id":"...","range":"C:F"}} +] +``` + +注意:batch 中不要用 `group-state:"fold"` 来创建折叠分组;需要折叠初始状态时,用独立 `dws sheet group-dimension --group-state fold`。 + ## 上下文传递 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `list` | 工作表的 `sheetId` | batch-clear 的 --ranges sheet 前缀 / batch-update 子操作 input 的 sheet-id | -| `batch-update` | 各子操作的执行结果 | 回读校验(range read / csv-get) | +| `batch-update` | 各子操作的执行结果 | 回读校验(值用 range read / csv-get;合并用 sheet info;分组用 sheet info --include groups) | ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - ★ **需要对多个区域执行相同清除操作时,用 `range batch-clear`**:一次原子请求清除多个区域(可跨工作表),失败时整批回滚 - ★ **需要组合多个不同写操作(清除+写入等)时,用 `batch-update`**:原子事务,任一操作失败则整批回滚,避免留下半成品 -- ★ **批次完成后必须回读校验**:用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与预期值对照 +- ★ **table 写入不进 batch-update**:当前 `batch-update` 不支持结构化 table 写入;结构化 table 请直接调用 `dws sheet table-put` +- ★ **批次完成后必须回读校验**:值写入用 `range read` 或 `csv-get` 抽样回读受影响区域;结构变更用 `sheet info` 回读 - ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update` -- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;`input.range` 只接受整行或整列范围,如 `3:7`、`C:F` -- 需要创建后立即折叠分组时,优先使用独立 `dws sheet group-dimension --group-state fold` +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;分组结果用 `sheet info --include groups` 回读 +- `batch-update` 中 `group-dimension` 只适合默认展开分组;需要 `fold` 初始状态时使用独立 `group-dimension` diff --git a/skills/mono/references/products/sheet/sheet-chart.md b/skills/mono/references/products/sheet/sheet-chart.md index 0295faae..df84da69 100644 --- a/skills/mono/references/products/sheet/sheet-chart.md +++ b/skills/mono/references/products/sheet/sheet-chart.md @@ -34,6 +34,7 @@ | "百分比面积" | 百分比堆积面积图(areaPercentStacked) | 占比式面积 | | "散点图"、"相关性" | 散点图(scatter) | 两变量相关性 | | "雷达图"、"多维度" | 雷达图(radar) | 多维度对比 | +| "柱+线"、"组合图"、"双轴" | 组合图(combo) | 多种图表叠加,支持副轴 | **多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。 @@ -55,8 +56,6 @@ Flags: --chart-id string 浮动图表 ID (可选,不传则返回全部) ``` -- **判空看 `totalCount` / `message`,不要看数组长度**:无图表时 `floatCharts` 数组里仍会带 1 个全 null 的幽灵元素(`chart.category`/`chart.series` 全 null,无 id),但 `totalCount` 为 0、`message` 为「Successfully retrieved 0 float chart(s).」。判断有没有图表以 `totalCount` 为准,或过滤掉没有 `id` 的元素。 - ### 创建浮动图表 ``` Usage: @@ -130,7 +129,7 @@ Flags: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `row` | number | 是 | 锚点行索引(0-based),如 0 表示第一行 | -| `col` | string | 是 | 锚点列。使用字母表示法(`"A"`=第1列, `"B"`=第2列, `"Z"`=第26列, `"AA"`=第27列),不支持数字形式 | +| `col` | string/number | 是 | 锚点列。支持字母表示法(`"A"`=第1列, `"B"`=第2列, `"Z"`=第26列, `"AA"`=第27列)或 0-based 数字(`0`=第1列) | **offset 字段**(可选,不传时默认 0): @@ -150,9 +149,10 @@ Flags: | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| `type` | string | 是 | 图表类型,枚举值:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked` / `pie` / `doughnut` / `scatter` / `radar` | -| `series` | object[] | 是 | 数据系列数组,至少一项,详见下方 series 字段表 | -| `category` | string[] | 否 | 分类轴(X轴)数据区域,A1 表示法字符串数组(如 `["A2:A10"]`) | +| `type` | string | 是 | 图表类型,枚举值:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked` / `pie` / `doughnut` / `scatter` / `radar` / `combo` | +| `series` | object[] | 非 combo 必填 | 数据系列数组,至少一项,详见下方 series 字段表。combo 类型不使用此字段 | +| `subCharts` | object[] | combo 必填 | 组合图子图数组,至少一项,详见下方 subCharts 字段表。仅 combo 类型使用 | +| `category` | string[] | 否 | 分类轴(X轴)数据区域,A1 表示法字符串数组(如 `["A2:A10"]`)。combo 类型在 subCharts 内声明 | | `title` | object | 否 | 图表标题配置,详见下方 title 字段表 | | `legend` | object | 否 | 图例配置,详见下方 legend 字段表 | | `catAx` | object | 否 | 分类轴(X轴)样式配置,详见下方轴配置表 | @@ -165,6 +165,15 @@ Flags: | `name` | string | 否 | 系列名称的单元格引用,A1 表示法(如 `"B1"`)。不传时图例显示为"系列1"等默认名 | | `value` | string[] | 是 | 系列数据区域,A1 表示法字符串数组(如 `["B2:B10"]`)。每个元素为一段连续区域 | +**subCharts 数组项**(仅 combo 类型): + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `type` | string | 是 | 子图类型,仅允许笛卡尔坐标系类型:`column` / `bar` / `columnStacked` / `barStacked` / `line` / `lineStacked` / `area` / `areaStacked` / `areaPercentStacked`。不允许 pie/doughnut/scatter/radar/combo | +| `series` | object[] | 是 | 子图数据系列,格式同上方 series 数组项 | +| `category` | string[] | 是 | 子图分类轴数据区域(笛卡尔坐标系必须提供) | +| `useSecondaryAxis` | boolean | 否 | 是否使用副轴(右侧 Y 轴),默认 false | + **title 字段**: | 字段 | 类型 | 必填 | 说明 | @@ -199,7 +208,7 @@ Flags: - 格式:`"A2:B10"`(默认使用 `--sheet-id` 对应的工作表) - 跨 sheet 引用:`"SHEET_ID!A2:B10"`(前缀为工作表 ID) - 单元格引用:`"B1"`(等同于 `"B1:B1"`) -- MCP 内部自动将 A1 引用转换为 ISheetRange 结构,CLI 直接透传即可 +- 直接传 A1 表示法字符串即可,命令会处理成图表所需的区域引用 ## 图表位置选择(创建前必做) @@ -277,32 +286,19 @@ dws sheet chart create --node --sheet-id --properties '{ } }' -# ── 工作流 4: 多图表组合分析 ── -# 当用户需要"柱+线"等多种图表组合分析时,分别创建对应类型的图表: +# ── 工作流 4: 创建组合图(combo)── +# 当用户需要"柱+线"叠加、双轴对比时,使用 combo 类型: -# 创建柱形图 dws sheet chart create --node --sheet-id --properties '{ "position": {"row": 40, "col": "A"}, - "dimensions": {"width": 700, "height": 450}, + "dimensions": {"width": 800, "height": 500}, "chart": { - "type": "column", - "series": [{"name": "B1", "value": ["B2:B10"]}], - "category": ["A2:A10"], - "title": {"show": true, "text": "销售额"}, - "legend": {"show": true, "pos": "t"} - } -}' - -# 创建折线图 -dws sheet chart create --node --sheet-id --properties '{ - "position": {"row": 40, "col": "J"}, - "dimensions": {"width": 700, "height": 450}, - "chart": { - "type": "line", - "series": [{"name": "C1", "value": ["C2:C10"]}], - "category": ["A2:A10"], - "title": {"show": true, "text": "增长率"}, - "legend": {"show": true, "pos": "t"} + "type": "combo", + "subCharts": [ + {"type": "column", "series": [{"name": "B1", "value": ["B2:B10"]}], "category": ["A2:A10"]}, + {"type": "line", "series": [{"name": "C1", "value": ["C2:C10"]}], "category": ["A2:A10"], "useSecondaryAxis": true} + ], + "title": {"show": true, "text": "销售额与增长率"} } }' ``` @@ -344,10 +340,10 @@ dws sheet chart update --node --sheet-id --chart-id --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - [强制] **创建后必须验证**:图表创建后必须调用 `chart list` 验证配置是否正确 -- ⚠️ **`chart list` 判空看 `totalCount` 不看数组长度**:无图表时 `floatCharts` 仍含 1 个全 null 幽灵元素(无 id),`totalCount` 为 0、`message` 说明为空。判断有没有图表以 `totalCount` 为准或过滤无 `id` 的元素 - [强制] **chart-id 禁止臆测**:必须通过 `chart list` 获取真实的图表 ID,不可编造 -- **图表类型映射**:用户说"柱形图"用 `column`,"条形图"(横向)用 `bar`,"散点图"用 `scatter`,"雷达图"用 `radar`,"环形图"用 `doughnut` +- **图表类型映射**:用户说"柱形图"用 `column`,"条形图"(横向)用 `bar`,"散点图"用 `scatter`,"雷达图"用 `radar`,"环形图"用 `doughnut`,"柱+线/组合/双轴"用 `combo` - **堆积类型后缀**:堆叠版本的图表在基本类型后加 `Stacked`(如 `columnStacked`、`lineStacked`、`areaStacked`) +- **combo 组合图**:不用顶层 `series`/`category`,改用 `chart.subCharts` 数组,每个子图独立声明 `type`/`series`/`category`,可选 `useSecondaryAxis: true` 指定副轴。子图 type 仅允许笛卡尔坐标系类型(column/bar/line/area 及其 Stacked 变体),不允许 pie/doughnut/scatter/radar/combo - **位置不得压在数据区上**:图表浮层会遮挡原始数据,必须放在空白区域 - **多图表需求**:多次调用 `chart create`,不要试图用一个图表包含所有分析 - **跨 sheet 引用数据**:在 A1 区域前加 `SHEET_ID!` 前缀,如 `"sheetXXX!A2:B10"` diff --git a/skills/mono/references/products/sheet/sheet-comment.md b/skills/mono/references/products/sheet/sheet-comment.md new file mode 100644 index 00000000..985e9813 --- /dev/null +++ b/skills/mono/references/products/sheet/sheet-comment.md @@ -0,0 +1,166 @@ +# sheet comment(表格单元格评论:list / create / reply / update / delete) + +> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: +> 1. [`../sheet.md`](../sheet.md) — 命令路由 + 场景索引 + 意图判断 + 全局约束 +> +> **同任务常配合**:`dws aisearch person`(查 `--mention` 用 userId)/ [`sheet-workbook.md`](sheet-workbook.md)(未知工作表名称时先 `list` 确认真实名称) + +--- + +## 适用范围 + +- 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。 +- 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。 +- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。 +- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。 + +--- + +## sheet comment list(查询表格评论列表) + +``` +Usage: + dws sheet comment list [flags] +Example: + dws sheet comment list --node + dws sheet comment list --node --sheet-id Sheet1 --range A2 + dws sheet comment list --node --resolve-status unresolved + dws sheet comment list --node --cursor +Flags: + --node string 目标表格的标识,支持传入 URL 或 ID (必填) + --limit int 每页返回的评论数量,默认 50,最大 50 + --cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) + --resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决) + --sheet-id string 工作表 ID 或名称,如 Sheet1(与 --range 一起指定时按单元格过滤) + --range string 单元格位置,A1 表示法,如 A2、B5:C10(与 --sheet-id 一起指定时按单元格过滤) +``` + +- 同时传入 `--sheet-id` 和 `--range` 时,仅返回该单元格的评论(服务端按单元格精确过滤);不传则返回表格全部单元格评论。 + +--- + +## sheet comment create(创建单元格评论) + +``` +Usage: + dws sheet comment create [flags] +Example: + dws sheet comment create --node --sheet-id Sheet1 --range A2 --content "这个数字有问题" + dws sheet comment create --node --sheet-id Sheet1 --range A2 --content "请核实" --mention uid1,uid2 +Flags: + --node string 目标表格的标识,支持传入 URL 或 ID (必填) + --sheet-id string 工作表 ID 或名称,如 Sheet1 (必填) + --range string 单元格位置,A1 表示法,仅支持单个单元格,如 A2 (必填) + --content string 评论的文字内容,纯文本 (必填) + --mention string 被 @ 的用户 uid 列表,逗号分隔 +``` + +- 未知工作表名称时,先 `dws sheet list --node --format json` 确认真实名称,禁止臆测 `Sheet1` / `0` / `default`。 + +--- + +## sheet comment reply(回复评论) + +``` +Usage: + dws sheet comment reply [flags] +Example: + dws sheet comment reply --node --comment-key --content "已核实" + dws sheet comment reply --node --comment-key --content "比心" --emoji + dws sheet comment reply --node --comment-key --content "请确认" --mention uid1,uid2 +Flags: + --node string 目标表格的标识,支持传入 URL 或 ID (必填) + --content string 回复的文字内容,表情回复时填写表情名称 (必填) + --comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填) + --emoji 设为 true 时作为表情贴图回复 (默认 false) + --mention string 被 @ 的用户 uid 列表,逗号分隔 +``` + +- 回复自动归属到被回复评论所在的单元格线程,无需再传 `--sheet-id` / `--range`。 + +--- + +## sheet comment update(更新评论) + +``` +Usage: + dws sheet comment update [flags] +Example: + dws sheet comment update --node --comment-key --content "已按最新数据修正" +Flags: + --node string 目标表格的标识,支持传入 URL 或 ID (必填) + --comment-key string 待更新评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填) + --content string 更新后的评论文字内容,纯文本 (必填) +``` + +--- + +## sheet comment delete(删除评论) + +> [强制] 危险操作:删除不可恢复。必须先向用户展示操作摘要并获得明确同意,用户同意后才加 `--yes` 执行。 + +``` +Usage: + dws sheet comment delete [flags] +Example: + dws sheet comment delete --node --comment-key --yes +Flags: + --node string 目标表格的标识,支持传入 URL 或 ID (必填) + --comment-key string 待删除评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填) +``` + +## 关键说明 + +- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws aisearch person --keyword "<姓名>" --dimension name` 拿到 userId。 +- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` 返回中提取,用于 `reply` / `update` / `delete`。 +- `create` / `list`(按单元格过滤)的 `--sheet-id` 是工作表 ID 或名称;未知时先 `dws sheet list` 确认,禁止臆测。 +- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。 +- `delete` 是不可逆操作;AI Agent 必须先向用户展示操作摘要并获得明确同意,同意后才追加 `--yes`。 + +## 上下文传递 + +| 从返回中提取 | 用于 | +|-------------|------| +| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` | +| `comment create` 的 `commentKey` | `comment reply/update/delete` 的 `--comment-key` | +| [`sheet-workbook.md`](sheet-workbook.md) `sheet list` 的工作表名称 | `comment create/list` 的 `--sheet-id` | +| `dws aisearch person` 的 `userId` | `comment create/reply` 的 `--mention` | + +## 常用模板 + +```bash +# 查看表格全部单元格评论 +dws sheet comment list --node --format json + +# 仅看某个单元格的评论 +dws sheet comment list --node --sheet-id Sheet1 --range A2 --format json + +# 仅看未解决的评论 +dws sheet comment list --node --resolve-status unresolved --format json + +# 在单元格上创建评论(未知工作表名先 sheet list 确认) +dws sheet list --node --format json +dws sheet comment create --node --sheet-id Sheet1 --range A2 --content "这个数字有问题" --format json + +# 创建评论 + @人(先 aisearch person 拿 userId) +dws aisearch person --keyword "张三" --dimension name --format json +dws sheet comment create --node --sheet-id Sheet1 --range A2 --content "请确认" --mention , --format json + +# 文字回复 +dws sheet comment reply --node --comment-key --content "已核实" --format json + +# 表情回复(--content 填表情名称) +dws sheet comment reply --node --comment-key --content "比心" --emoji --format json + +# 更新评论 +dws sheet comment update --node --comment-key --content "已按最新数据修正" --format json + +# 删除评论(不可逆;必须用户确认后再加 --yes) +dws sheet comment delete --node --comment-key --yes --format json +``` + +## 参考 + +- [`../sheet.md`](../sheet.md)(如何路由到本命令族) +- [`./sheet-workbook.md`](sheet-workbook.md)(取工作表名称) +- `dws aisearch person`(取 mention 用的 userId,跨产品命令) diff --git a/skills/mono/references/products/sheet/sheet-conditional-format.md b/skills/mono/references/products/sheet/sheet-conditional-format.md index 70bc77b7..ccee67c4 100644 --- a/skills/mono/references/products/sheet/sheet-conditional-format.md +++ b/skills/mono/references/products/sheet/sheet-conditional-format.md @@ -64,14 +64,13 @@ **正确做法(两步走)**: ```bash -# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列) -# 每个单元格必须是 object,公式写在 text(以 = 开头);不支持裸字符串 -dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H100" \ - --values '[[{"type":"text","text":"=IF(A2>B2,\"是\",\"否\")"}],...]' +# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列,以 H2:H4 为例) +dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H4" \ + --values '[[{"type":"text","text":"=IF(A2>B2,\"是\",\"否\")"}],[{"type":"text","text":"=IF(A3>B3,\"是\",\"否\")"}],[{"type":"text","text":"=IF(A4>B4,\"是\",\"否\")"}]]' # Step 2: 基于辅助列值做条件格式(用 formulaCondition 引用辅助列) dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \ - --ranges '["A2:H100"]' \ + --ranges '["A2:H4"]' \ --condition '{"formulaCondition":{"formula":"=$H2=\"是\""}}' \ --cell-style '{"backgroundColor":"#FFECEC"}' ``` @@ -143,8 +142,7 @@ Flags: - **用途**:查看指定工作表中已有的条件格式规则,或获取单个规则的详情。 - **场景**:创建/更新/删除条件格式前后验证规则状态;获取 ruleId 供后续 update/delete 使用。 -- **返回**:`rules` 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。 -- **判空要看 `message`,不要看数组长度**:无规则时 `rules` 数组里仍会带 1 个全 null 的幽灵元素(`colorScaleCondition`/`dataBarStyle`/`iconSetCondition` 全 null,无 id),`message` 会是「No conditional formatting rules found.」。该命令**没有 `totalCount` 字段**,判空以 message 为准,或过滤掉没有 `id` 的元素。 +- **返回**:rules 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。 ### 创建条件格式规则 ``` @@ -271,4 +269,3 @@ Flags: - **辅助列+条件格式两步走**:用户明确要求"辅助列"时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),禁止直接用 `formulaCondition` 一步绕过 - **大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入 - **判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规 -- ⚠️ **`cond-format list` 判空看 `message` 不看数组长度**:无规则时 `rules` 仍含 1 个全 null 幽灵元素(无 id),`message` 为「No conditional formatting rules found.」。该命令无 `totalCount`,判空以 message 为准或过滤无 `id` 的元素 diff --git a/skills/mono/references/products/sheet/sheet-dimension-operations.md b/skills/mono/references/products/sheet/sheet-dimension-operations.md index afc2f5ac..c039f548 100644 --- a/skills/mono/references/products/sheet/sheet-dimension-operations.md +++ b/skills/mono/references/products/sheet/sheet-dimension-operations.md @@ -14,7 +14,7 @@ 用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性": - 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false` -- 设置行高/列宽 → `update-dimension --pixel-size` +- 设置行高/列宽 → `update-dimension --pixel-size`;恢复默认尺寸 → `--size-type standard`;行高按内容自适应 → `--size-type auto`(列宽不提供 auto) - 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden` 用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行": @@ -26,11 +26,12 @@ - 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 - 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 -用户说"创建行分组/创建列分组/折叠几行/折叠几列/展开行列分组/取消行列分组": -- 创建分组 → `group-dimension --range <整行或整列范围>` -- 创建并折叠 → `group-dimension --group-state fold` -- 取消分组 → `ungroup-dimension --range <整行或整列范围>` -- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7` +用户说"创建行分组/创建列分组/新建分组并折叠/新建分组并展开/取消分组": +- 创建连续行/列分组 → `group-dimension` +- 取消连续行/列分组 → `ungroup-dimension` +- 分组创建后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups` +- 仅创建分组时可用 `--group-state expand|fold` 指定初始展开/折叠;当前没有单独调整已有分组折叠状态的命令 +- 用户要求"折叠/展开已有分组"时,当前不支持直接修改已有分组的 `collapsed` 状态,不能承诺可用 `group-dimension` 完成 **结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node --sheet-id --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。 @@ -116,6 +117,12 @@ Example: # 使用工作表前缀(忽略 --sheet-id) dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden + + # 恢复第 2~3 行为默认行高(无需 --pixel-size) + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "2" --length 2 --size-type standard + + # 第 2~3 行按内容自适应行高 + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "2" --length 2 --size-type auto Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) @@ -124,14 +131,25 @@ Flags: --length string 更新数量,正整数 (必填),最大 5000 --hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一 --pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一 + --size-type string 尺寸模式: pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项) ``` 批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。 `--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。 支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 -`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 +`--hidden` 与 `--pixel-size` 至少必须提供一个(或用 `--size-type standard/auto` 让服务端决定尺寸)。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。 +`--size-type` 尺寸模式(对齐飞书 row_sizes/col_sizes 的 type): + +| 值 | 行为 | ROWS | COLUMNS | 是否需要 --pixel-size | +|----|------|------|---------|----------------------| +| `pixel`(默认) | 使用 `--pixel-size` 指定的像素值 | 支持 | 支持 | 需要 | +| `standard` | 恢复为工作表默认行高/列宽 | 支持 | 支持 | 不需要 | +| `auto` | 按内容自适应行高 | 支持 | 不提供 | 不需要 | + +列宽不提供 `auto`(与飞书 `col_sizes` 的 type 枚举一致,只有 `pixel` / `standard`)。需要控制列宽时用 `--pixel-size` 指定,或用 `--size-type standard` 恢复默认列宽。 + ### 移动行或列 ``` Usage: @@ -183,32 +201,51 @@ Flags: Usage: dws sheet group-dimension [flags] Example: + # 创建第 3~7 行分组,默认展开 dws sheet group-dimension --node --sheet-id --range "3:7" + + # 创建 C~F 列分组,并在创建后折叠 dws sheet group-dimension --node --sheet-id --range "C:F" --group-state fold + + # 使用工作表前缀(忽略 --sheet-id) dws sheet group-dimension --node --sheet-id --range "Sheet1!3:7" Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) - --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) - --group-state string 创建后的分组状态: expand 或 fold,默认 expand + --range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F" + --group-state string 创建后的分组状态: expand 或 fold (默认 expand) ``` -为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持 `A1:C5` 这类普通单元格矩形范围。 +创建连续整行或整列分组。`--range` 只能是整行范围或整列范围,不支持普通单元格矩形(如 `A1:C5`)。支持单行/单列(如 `"3:3"` / `"C:C"`)以及带工作表前缀(如 `Sheet1!3:7`),带前缀时忽略 `--sheet-id`。 + +返回中使用 `level` 表示分组展示层级,`level` 为 1-based;不要查找或依赖 `depth`。`--group-state fold` 只用于创建后的初始折叠状态,当前不提供"仅修改已有分组 collapsed 状态"的独立命令。 + +创建后用以下命令回读: +```bash +dws sheet info --node --sheet-id --include groups --format json +``` + +回读字段为 `rowGroups` / `columnGroups`,单项包含 `range`、起止行列、`count`、`level`、`collapsed`。`collapsed=true` 表示当前分组折叠。 ### 取消行或列分组 ``` Usage: dws sheet ungroup-dimension [flags] Example: + # 取消第 3~7 行分组 dws sheet ungroup-dimension --node --sheet-id --range "3:7" + + # 取消 C~F 列分组 dws sheet ungroup-dimension --node --sheet-id --range "C:F" Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) - --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) + --range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F" ``` -取消指定连续行或连续列的分组。 +取消指定连续整行或整列范围上的分组。取消后同样用 `sheet info --include groups` 回读确认目标 `range` 已从 `rowGroups` / `columnGroups` 中移除。 + +`group-dimension` / `ungroup-dimension` 可以放进 `batch-update` 做原子组合;但 batch 中的 `group-dimension` 只适合默认展开分组。需要创建后立即折叠时,请使用独立 `dws sheet group-dimension --group-state fold`。 ## 核心工作流 @@ -282,15 +319,15 @@ dws sheet update-dimension --node --sheet-id \ | `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 | | `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | | `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | -| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 | -| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 | +| `group-dimension` | `range` / `level` / `groupState` | 确认创建的分组范围、层级和初始状态 | +| `ungroup-dimension` | `range` / `level` | 确认取消分组的范围和层级 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `sheet info` 的 `mergedRanges` 是行列结构操作的重要预检信息。插入列时尤其要检查多行表头合并区,原有合并区域通常不会自动扩展到新列,必要时需重新设置合并区域 -- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append` +- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`;如需"先插入行列再写入数据"的组合操作,使用 [batch-update](./sheet-batch-operations.md) 打包为原子事务 - `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS` - `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id` - `insert-dimension` 的 `--length` 最大为 5000 @@ -313,7 +350,8 @@ dws sheet update-dimension --node --sheet-id \ - `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 - `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 -- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5` -- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand` -- 分组操作返回 `level` 表示当前分组层级;不要把它误认为固定深度参数 -- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold` +- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,不接受普通单元格矩形 +- `group-dimension` 输出和 `sheet info --include groups` 回读均使用 `level`,且为 1-based;不要使用旧的 `depth` 字段 +- `sheet info --include groups` 是分组回读入口;裸 `sheet info` 不承诺返回行列分组,`range read` / `csv-get` 不返回行列分组 +- 当前不能直接调整已有分组的 `collapsed` 状态;`--group-state fold` 只在创建分组时生效 +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`,但不适合创建后立即折叠;需要 `fold` 时使用独立 `group-dimension` diff --git a/skills/mono/references/products/sheet/sheet-dropdown.md b/skills/mono/references/products/sheet/sheet-dropdown.md index 173594e2..0b135aa4 100644 --- a/skills/mono/references/products/sheet/sheet-dropdown.md +++ b/skills/mono/references/products/sheet/sheet-dropdown.md @@ -55,11 +55,10 @@ Flags: --range string 查询范围,A1 表示法,如 A1:A100 (必填) ``` -查询指定范围内的下拉列表配置信息,包括选项值和颜色。 +查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `multipleValues` 和 `colorValueMap`)。**判空以 `hasDropdown` 为准**:范围内无下拉列表时 `hasDropdown` 为 false,但 `dataValidations` 里仍会带 1 个全 null 的幽灵条目,不要用数组长度判空。 -- ⚠️ **已知限制**:`options.multipleValues` 服务端恒返回 `null`(即使该下拉是用 `--multi-select` 建的),也不返回 `enableMultiSelect` 字段——**`get-dropdown` 读回无法判断该下拉是否多选**。要判断是否多选,改用 `range read`:它返回的 `cells[].dataValidation.enableMultiSelect` 字段是准确的(实测有效)。 +- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -91,6 +90,5 @@ Flags: - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 -- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。判空以 `hasDropdown` 为准(无下拉时为 false,但 `dataValidations` 仍含 1 个全 null 幽灵条目) -- ⚠️ `get-dropdown` 的 `options.multipleValues` 恒为 `null`、无 `enableMultiSelect` 字段,读回无法判断是否多选;要判断是否多选改用 `range read`(其 `dataValidation.enableMultiSelect` 准确) +- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index 891da573..ba0880c6 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -11,6 +11,11 @@ - 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) - 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) +用户说"导出 CSV/存成 csv/导出这个工作表为 csv": +- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) +- 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式 +- 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理 + ## 命令详细参考 ### 导出表格为 xlsx(异步任务一站式) @@ -47,17 +52,60 @@ Flags: - 第 21~30 次:每次间隔 15 秒 - **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误 -**命令返回**(`--format json`,默认):输出规整 JSON `{success, jobId, downloadUrl[, outputPath]}`。轮询进度以 `[INFO] [N/30] 状态: ...` 打到 stderr,不污染 stdout 的 JSON(可直接 `python3 -m json.tool` 解析 stdout)。 -- `--output` 未指定:JSON 含 `jobId` + `downloadUrl`(链接有时效性,请尽快下载) -- `--output` 指定为文件路径:下载到该路径,JSON 额外含 `outputPath` -- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名保存,JSON 含 `outputPath` +**命令返回**: +- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载) +- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: ` +- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下 **失败处理(命令内部已处理,Agent 仅需转述)**: -- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 +- 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 **限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +### 导出单个工作表为纯 CSV(同步) +``` +Usage: + dws sheet export-csv [flags] +Example: + dws sheet export-csv --node + dws sheet export-csv --node --sheet-id --output ./data.csv + dws sheet export-csv --node --range A1:Z1000 --value-render-option raw_value + +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称(不传则第一个工作表) + --range string 导出范围,A1 表示法(不传则整表;大表可用此分块导出) + --value-render-option string 取值模式: formatted_value(默认) / raw_value / formula + --output string 本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout + --allow-truncated 允许数据被截断时仍然导出。默认截断即报错且不写文件 +``` + +同步读取**单个**工作表并输出 RFC4180 CSV,不走异步导出任务。与 `export` 是两条独立命令:`export` 导整篇工作簿的 xlsx(异步提交+轮询),`export-csv` 只导一个工作表的纯值(一次请求即返回)。不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理。 + +**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。`--output` 指向已存在目录时保存为该目录下的 `sheet-export.csv`。 + +**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: +- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) +- 改用 `dws sheet export` 导出完整表格的 xlsx +- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格" + +```bash +# 落盘到本地 +dws sheet export-csv --node --sheet-id --output ./data.csv + +# 输出到 stdout 便于管道处理 +dws sheet export-csv --node --sheet-id + +# 大表分块导出(避免截断;不分块时默认会因截断而报错) +dws sheet export-csv --node --sheet-id --range "A1:Z1000" --output ./part1.csv + +# 明确接受不完整数据(否则截断即失败) +dws sheet export-csv --node --sheet-id --allow-truncated --output ./partial.csv +``` + +注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用 `dws sheet export` 导 xlsx。只是让 Agent 读取内容(带 `[row=N]` 行号前缀)请用 `dws sheet csv-get`。 + ## 核心工作流 ```bash @@ -83,6 +131,8 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | +| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 @@ -93,3 +143,5 @@ dws sheet export --node --output ./ - `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 - `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 - 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export` +- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated` diff --git a/skills/mono/references/products/sheet/sheet-filter-view.md b/skills/mono/references/products/sheet/sheet-filter-view.md index 631a6c6f..89b1e0d3 100644 --- a/skills/mono/references/products/sheet/sheet-filter-view.md +++ b/skills/mono/references/products/sheet/sheet-filter-view.md @@ -219,7 +219,6 @@ Flags: - **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。 - **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。 - **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。 -- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。 ### 列出筛选视图所有列条件 ``` @@ -238,7 +237,6 @@ Flags: - **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。 - **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。 - **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。 -- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。 ### 获取单列筛选条件 ``` @@ -261,7 +259,6 @@ Flags: - **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。 - **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。 - **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。 -- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。 ## 核心工作流 @@ -328,7 +325,7 @@ dws sheet filter-view create --node --sheet-id \ - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列 - `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id` -- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤 +- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),按 `--filter-view-id` 返回对应视图 - `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}` - `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示 - `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置 diff --git a/skills/mono/references/products/sheet/sheet-filter.md b/skills/mono/references/products/sheet/sheet-filter.md index 36d69dc4..0e65607b 100644 --- a/skills/mono/references/products/sheet/sheet-filter.md +++ b/skills/mono/references/products/sheet/sheet-filter.md @@ -32,7 +32,7 @@ Flags: - **用途**:查看当前工作表上是否存在全局筛选及其配置。 - **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。 - **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。 -- **返回**:`range`(筛选范围,A1 表示法)、`id`(筛选 ID)和 `criteria`(各列条件对象,key 为列偏移量;无条件时为 `{}`)。如果未设置筛选,返回筛选信息为空。 +- **返回**:`range`(筛选范围,A1 表示法)和 `columnFilterCriteria`(各列条件,key 为列偏移量)。如果未设置筛选,返回筛选信息为空。 ### 创建筛选 ``` @@ -152,7 +152,7 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `filter get` | `range`(筛选范围)、`id`、`criteria`(各列条件对象,无条件为 `{}`) | 查看当前筛选配置,确认筛选是否存在 | +| `filter get` | `range`(筛选范围)、`columnFilterCriteria`(各列条件) | 查看当前筛选配置,确认筛选是否存在 | | `filter create` | 筛选创建成功的确认 | 确认筛选已建立,后续可通过 `filter update` 设置条件 | | `filter delete` | 删除成功的确认 | 确认筛选已删除 | | `filter update` | 更新成功的确认 | 确认条件已设置 | @@ -164,7 +164,7 @@ Flags: - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列 -- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)、`id` 和 `criteria`(各列条件对象,无条件时为 `{}`)。无筛选时返回空 +- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)和 `columnFilterCriteria`(各列条件)。无筛选时返回空 - `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错 - `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件 - `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复 diff --git a/skills/mono/references/products/sheet/sheet-formula.md b/skills/mono/references/products/sheet/sheet-formula.md new file mode 100644 index 00000000..cf63e322 --- /dev/null +++ b/skills/mono/references/products/sheet/sheet-formula.md @@ -0,0 +1,192 @@ +# 公式写入、回读与错误校验 + +## 使用场景 + +用户说"写公式/计算列/辅助列/总计/占比/增长率/查找计算/自动计算/校验公式/检查公式错误"时使用本页。 + +- 能由表内其他单元格推导的派生值,优先写公式,不要写一次性的静态结果。 +- 写公式前先读表头和 3-5 行样本,确认列含义、数据类型、真实行号和目标范围。 +- 用户明确要求"辅助列"时,需要真实写入辅助列公式;不要只用条件格式或本地计算绕过。 + +## 当前能力边界 + +- 写少量或需要单元格对象的公式:使用 `dws sheet range update`。 +- 从 CSV/表格文本批量写公式:使用 `dws sheet csv-put`,字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号。 +- 公式载体:公式写在 cell object 的 `text` 字段中,例如 `{"type":"text","text":"=SUM(B2:B10)"}`。 +- 读取公式文本:使用 `dws sheet range read --value-render-option formula`。 +- 读取计算结果:使用 `dws sheet range read --value-render-option raw_value` 或默认 `formatted_value`。 +- 聚合错误校验:使用 `dws sheet formula-verify`,支持整本表格、单个目标和多个目标。 +- `formula-verify` 扫描已经落表的公式计算结果,按 `#ERROR!` / `#NAME?` / `#DIV/0!` 等错误类型汇总;它不判断一个正常数值是否符合业务预期。 +- `append` / `table-put` 不作为公式写入协议;需要公式时用 `range update` 或 `csv-put`。 + +## 命令选择 + +| 目的 | 命令 | 说明 | +|------|------|------| +| 写入少量或中等范围公式 | `range update` | `--values` 必须是二维 cell object,维度与 `--range` 完全一致 | +| 从 CSV/表格文本批量写公式 | `csv-put` | `=` 开头按公式;前导单引号写入以 `=` 开头的字面文本;不支持富格式对象 | +| 查看已写入的公式文本 | `range read --value-render-option formula` | 确认公式本身是否落表、范围和引用是否正确 | +| 查看公式计算结果 | `range read --value-render-option raw_value` | 用于数值对账、错误值检查 | +| 查看格式化展示结果 | `range read` 或 `csv-get` 默认模式 | 用于用户肉眼看到的展示值检查 | +| 扫描整本表格公式错误 | `formula-verify --node ` | 不传目标时扫描全部工作表的非空范围 | +| 扫描单个工作表或范围 | `formula-verify --sheet-id ... [--range ...]` | `--range` 只传 A1 范围,不带工作表前缀 | +| 扫描多个目标 | `formula-verify --targets ...` | 必须是非空数组;每项为 `{"sheetId":"...","range":"..."}`,`range` 可省略 | + +## 推荐流程 + +1. 用 `dws sheet list --node --format json` 获取真实 `sheetId`。 +2. 用 `range read` 或 `csv-get` 读取表头和样本数据,确认目标列与行号。 +3. 明确相对引用和绝对引用:向下填充时检查固定汇率、税率、查找表、标题行是否需要 `$` 锁定。 +4. 按数据形态写入公式:精确 cell object 用 `range update`,CSV/表格文本用 `csv-put`。`range update` 的矩阵行列数必须与 `--range` 完全一致。 +5. 用 `range read --value-render-option formula` 回读公式文本,确认实际公式、范围和引用。 +6. 对本次写入目标运行 `formula-verify`;若返回 `partial` / `hasMore=true`,缩小目标或提高 `--max-cells` 后继续扫描,直到结果完整。 +7. 用 `range read --value-render-option raw_value` 抽样对账业务数值;正常数值不会被 `formula-verify` 判定为业务计算错误。 +8. 若发现错误,先定位依赖单元格、空值、除数为 0、引用范围越界或函数名错误,再重写公式并重新执行文本回读、错误扫描和数值抽样。 + +## 聚合式公式校验 + +### 整本表格 + +不指定 `--sheet-id`、`--range` 或 `--targets` 时,扫描整本表格的全部工作表: + +```bash +dws sheet formula-verify --node --format json +``` + +### 单个工作表或范围 + +`--sheet-id` 支持工作表 ID 或名称;省略 `--range` 时扫描该工作表的非空范围: + +```bash +dws sheet formula-verify --node --sheet-id --format json + +dws sheet formula-verify --node --sheet-id \ + --range "D2:D100" --format json +``` + +`--range` 必须和 `--sheet-id` 一起使用,且只传 `D2:D100` 这类 A1 范围,不能传 `Sheet1!D2:D100`。 + +### 多个目标 + +```bash +dws sheet formula-verify --node \ + --targets '[{"sheetId":"Sheet1","range":"D2:D100"},{"sheetId":"Summary"}]' \ + --format json +``` + +`--targets` 也支持 `@targets.json` 和 `-`(stdin)。数组必须至少包含一个目标,不能传 `[]`;每项只允许非空 `sheetId` 和可选的字符串 `range`,`range` 只写 A1 范围且不能带工作表前缀。使用 `--targets` 时不能再传 `--sheet-id` 或 `--range`。 + +### 扫描限制与自动化 + +```bash +dws sheet formula-verify --node \ + --max-locations-per-error 20 --max-cells 30000 --format json + +dws sheet formula-verify --node --exit-on-error --format json +``` + +- `--max-locations-per-error` 只限制每类错误返回的 `locations` 和 `samples` 数量,`count` 与 `totalErrors` 仍保留实际扫描到的总数。 +- `--max-cells` 是本次调用跨全部 targets 共享的扫描预算;预算不足时返回 `status=partial`、`hasMore=true`。 +- `--exit-on-error` 适合 CI/自动化:发现公式错误时打印 JSON 结果并返回非 0;`partial` 结果中已经发现错误时同样返回非 0。 + +### 结果判定 + +| 字段 | 含义 | +|------|------| +| `status` | `success` / `errors_found` / `partial` | +| `totalErrors` | 实际扫描到的错误公式单元格总数 | +| `totalFormulas` | 实际扫描到的公式单元格总数 | +| `scannedCells` | 实际扫描的单元格数 | +| `hasMore` | `true` 表示结果不完整,不能据此声称目标范围零错误 | +| `errorSummary` | 按错误类型聚合的 `count`、`locations` 和 `samples` | +| `warningMessage` | `partial` 等情况下的扫描限制提示 | + +判定规则: + +- `status=success`、`hasMore=false`、`totalErrors=0`:本次目标范围未发现公式错误。 +- `status=errors_found`:按 `errorSummary` 修复后重新校验。 +- `status=partial` 或 `hasMore=true`:当前结果不完整;缩小 targets/range 或提高 `--max-cells` 后继续校验。 + +## 写入示例 + +### 单格公式 + +```bash +dws sheet range update --node --sheet-id --range "D2" \ + --values '[[{"type":"text","text":"=B2*C2"}]]' --format json +``` + +### 整列公式 + +```bash +dws sheet range update --node --sheet-id --range "D2:D5" \ + --values '[ + [{"type":"text","text":"=B2*C2"}], + [{"type":"text","text":"=B3*C3"}], + [{"type":"text","text":"=B4*C4"}], + [{"type":"text","text":"=B5*C5"}] + ]' --format json +``` + +### 含绝对引用 + +税率在 `G1` 时,向下填充应锁定税率单元格: + +```bash +dws sheet range update --node --sheet-id --range "E2:E5" \ + --values '[ + [{"type":"text","text":"=D2*$G$1"}], + [{"type":"text","text":"=D3*$G$1"}], + [{"type":"text","text":"=D4*$G$1"}], + [{"type":"text","text":"=D5*$G$1"}] + ]' --format json +``` + +## 公式文本与结果回读 + +### 1. 回读公式文本 + +```bash +dws sheet range read --node --sheet-id --range "D2:D5" \ + --value-render-option formula --format json +``` + +检查点: +- `value` 应返回以 `=` 开头的公式文本。 +- 行号、列号、相对引用、绝对引用应与写入计划一致。 +- 无公式的单元格在 `formula` 模式下可能回退为原始值,不能把这种回退误判为公式已写入。 + +### 2. 回读计算结果 + +```bash +dws sheet range read --node --sheet-id --range "D2:D5" \ + --value-render-option raw_value --format json +``` + +检查点: +- 数值结果应与样本手算或本地复算一致。 +- 检查结果中是否出现 `#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`。 +- 对大范围公式,至少抽样检查首行、末行、边界行和异常数据行;用户要求全量处理时,应分批回读并断言处理数量。 + +### 3. 数值正确性边界 + +`formula-verify` 负责聚合已经落表的公式错误值;`range read` 负责确认实际公式文本和具体计算结果。即使 `formula-verify` 返回 `success`,也仍需对金额、比例、汇率、边界行等关键业务结果做 `raw_value` 抽样对账,因为一个公式可能计算出合法数值但业务逻辑仍然写错。 + +## 常见错误 + +- 想用 `csv-put` 写入 `=SUM(...)` 文本却忘记加前导单引号,导致内容被解析为公式。 +- 用原始二维数组 `--values '[["=B2*C2"]]'`,而不是 cell object。 +- 写整列公式时只写第一行,忘记把 `--range` 和 `--values` 扩成同样行数。 +- 复制公式时没有锁定固定引用,例如税率、汇率、查找表范围。 +- 没有回读 `formula` 模式,只看写入返回 `success`。 +- 只回读展示值,不运行 `formula-verify` 聚合扫描错误。 +- 把 `max-locations-per-error` 误解为错误计数上限;它只截断位置和样本。 +- 看到 `status=partial` 或 `hasMore=true` 仍声称整本表公式零错误。 +- 在 `--range` 中传 `Sheet1!A1:D10`,或把 `--targets` 与 `--sheet-id` 混用。 +- 显式传空的 `--targets '[]'`;这不是“无目标”,应改为至少一个目标,整本扫描则完全省略 `--targets`。 + +## 关联文档 + +- [sheet-write-data](./sheet-write-data.md):`range update` 的 `--values` cell object 结构、维度校验、富格式能力。 +- [sheet-read-data](./sheet-read-data.md):`value-render-option` 的 `formatted_value` / `raw_value` / `formula` 读取模式。 +- [sheet-conditional-format](./sheet-conditional-format.md):条件格式中的 `formulaCondition` 与辅助列公式的职责边界。 diff --git a/skills/mono/references/products/sheet/sheet-import.md b/skills/mono/references/products/sheet/sheet-import.md index 2aa0a946..d50b27b4 100644 --- a/skills/mono/references/products/sheet/sheet-import.md +++ b/skills/mono/references/products/sheet/sheet-import.md @@ -6,7 +6,7 @@ - 支持本地 `xlsx` / `xls`,文件上限 20MB - `--folder-token` 与 `--workspace` 至少提供一个 -- CLI 内部完成创建会话、OSS 上传、确认转换和轮询,Agent 不要自行拆分或重试 +- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试 - `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令 ## 命令 @@ -71,6 +71,6 @@ dws sheet import get --task-id --format json ## 边界 - 本命令接受的是本地文件路径;它把 xlsx/xls 转换成新的 axls 在线表格 -- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `doc download` 下载,需要在线编辑时再执行 `sheet import create` +- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `drive download` 下载,需要在线编辑时再执行 `sheet import create` - 向已有在线表格写数据应使用 `range update`、`append`、`csv-put` 或 `table-put` - md/doc/docx 等文字文档导入使用 `dws doc import` diff --git a/skills/mono/references/products/sheet/sheet-media-image.md b/skills/mono/references/products/sheet/sheet-media-image.md index 060c55c9..06aba4b7 100644 --- a/skills/mono/references/products/sheet/sheet-media-image.md +++ b/skills/mono/references/products/sheet/sheet-media-image.md @@ -11,7 +11,7 @@ 用户说"写入图片/插入图片/加图片/放图片到单元格/嵌入图片到表格": - 写入图片 → `write-image`(需表格 ID + 工作表 ID + 单元格范围 + 本地图片路径) -- 禁止使用 `range update` 写入图片,因为 `update_range` 的 MCP 工具不支持图片类型参数,调用必定失败。必须使用 `write-image` 命令 +- 禁止使用 `range update` 写入图片;图片对象必须使用 `write-image` 命令 - 用户指定了图片尺寸 → `write-image --width N --height M` ### 浮动图片 @@ -48,8 +48,6 @@ Flags: --mime-type string 文件 MIME 类型 (默认根据扩展名推断) ``` -`--format json` 输出规整 JSON:`{success, resourceId, resourceUrl, fileName, fileSize}`。`resourceUrl` 可用于 `create-float-image` 的 `--src`。 - ### 上传图片并写入表格单元格 ``` Usage: @@ -205,10 +203,13 @@ dws sheet write-image --node --sheet-id --range C3:C3 --fil # 4. 完整流程: 创建表格 → 写表头 → 写入图片 dws sheet create --name "产品目录" -f json -# 提取 nodeId 后: -dws sheet range update --node --sheet-id Sheet1 --range "A1:B1" --values '[[{"type":"text","text":"产品名称"},{"type":"text","text":"产品图片"}]]' -f json -dws sheet range update --node --sheet-id Sheet1 --range "A2:A2" --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json -dws sheet write-image --node --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json +# 提取 nodeId 后,先用 list 获取真实 sheetId: +dws sheet list --node -f json +dws sheet range update --node --sheet-id --range "A1:B1" \ + --values '[[{"type":"text","text":"产品名称"},{"type":"text","text":"产品图片"}]]' -f json +dws sheet range update --node --sheet-id --range "A2:A2" \ + --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json +dws sheet write-image --node --sheet-id --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json ``` ## 上下文传递 @@ -229,7 +230,7 @@ dws sheet write-image --node --sheet-id Sheet1 --range B2:B2 --file ./ - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 - `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 -- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败 +- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 - `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL - `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) diff --git a/skills/mono/references/products/sheet/sheet-pivot-table.md b/skills/mono/references/products/sheet/sheet-pivot-table.md index 88547ca0..6b8f93d2 100644 --- a/skills/mono/references/products/sheet/sheet-pivot-table.md +++ b/skills/mono/references/products/sheet/sheet-pivot-table.md @@ -1,67 +1,292 @@ -# 透视表管理 +# 透视表 (pivot-table) + +## 真对象硬约束 + +当用户要求"透视表 / 分组汇总 / 交叉分析 / 按 X 统计 Y"时,**必须**通过 `pivot-table create` 创建真实的透视表对象。**禁止**用 `SUMIFS` / `COUNTIFS` 等普通公式 + `csv-put` 在原表中拼一张"看起来像透视表的汇总表"来代替——静态公式无法随源数据动态更新,且失去交互能力。判断标准:交付后 `pivot-table list` 必须能返回该对象。 ## 使用场景 -- 用户要按部门、月份等维度汇总数据 → `pivot-table create` -- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list` -- 用户要调整行列、聚合方式或显示选项 → `pivot-table update` -- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认) +读写透视表对象。本 reference 覆盖 4 个命令: -透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。 +| 操作需求 | 使用命令 | 说明 | +|---------|---------|------| +| 查看已有透视表 | `pivot-table list` | 获取透视表的结构、数据源和配置 | +| 创建透视表 | `pivot-table create` | 创建透视表对象 | +| 更新透视表 | `pivot-table update` | 更新透视表配置(行/列/值/筛选字段) | +| 删除透视表 | `pivot-table delete` | 删除透视表 | -## 列出与查看 +典型工作流:先读取现有透视表了解配置 -> 执行创建/更新/删除 -> 再次读取验证结果。 -```bash -dws sheet pivot-table list --node --sheet-id -dws sheet pivot-table list --node --sheet-id --pivot-table-id +## 行/值字段映射(创建前必做) + +创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**: + +- **rows(行字段)** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别 +- **values(值字段)** = 聚合指标,即"统计什么数值"。例:销售额(`summarize_by: "sum"`)、订单数(`summarize_by: "count"`) +- **columns(列字段)** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别 + +| 用户说 | rows | values | columns | +|--------|------|--------|---------| +| "按部门统计人数" | 部门 | 姓名(`"count"`) | -- | +| "按医生统计费用和结余" | 主管医生 | 费用(`"sum"`)、结余(`"sum"`) | -- | +| "各部门男女人数" | 部门 | 姓名(`"count"`) | 性别 | + +**常见配置错误(必须注意)**: +- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误 +- **行列字段选择要匹配用户意图**:用户说"按商品统计金额" -> 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反 +- **聚合类型要匹配**:用户说"统计数量" -> `"count"`;"统计总额" -> `"sum"`;"统计平均" -> `"average"` +- **创建后必须验证**:调用 `pivot-table list` 确认透视表结构正确 + +## 命令详细参考 + +### 获取透视表 +``` +Usage: + dws sheet pivot-table list [flags] +Example: + # 列出所有透视表 + dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID + + # 获取单个透视表详情 + dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --pivot-table-id string 透视表 ID (可选,不传则返回全部) ``` -不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。 +### 创建透视表 +``` +Usage: + dws sheet pivot-table create [flags] +Example: + # 按部门统计销售额(默认自动新建工作表存放) + dws sheet pivot-table create --node NODE_ID \ + --source "'Sheet1'!A1:D100" \ + --properties '{ + "rows": [{"field": "部门"}], + "values": [{"field": "销售额", "summarize_by": "sum"}], + "show_row_grand_total": true + }' -## 创建 + # 指定放置到已有工作表的特定位置 + dws sheet pivot-table create --node NODE_ID \ + --source "'Sheet1'!A1:E200" \ + --target-sheet-id TARGET_SHEET_ID --target-position "A1" \ + --properties '{ + "rows": [{"field": "部门"}], + "values": [{"field": "销售额", "summarize_by": "sum"}] + }' + + # 通过文件传入配置 + dws sheet pivot-table create --node NODE_ID \ + --source "'Sheet1'!A1:D50" --properties @pivot.json +Flags: + --node string 表格文档 ID 或 URL (必填) + --source string 数据源区域,A1 表示法含 sheet 前缀 (必填,如 "'Sheet1'!A1:D100") + --properties string 透视表配置 JSON (必填,含 rows/columns/values/filters) + --target-sheet-id string 目标工作表 ID 或名称 (可选,不传则自动新建工作表) + --target-position string 透视表放置位置 (可选,A1 格式单个 cell,如 "B5",不传默认 A1) +``` + +### 更新透视表 +``` +Usage: + dws sheet pivot-table update [flags] +Example: + # 先获取现有配置 + dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID + + # 修改后回写 + dws sheet pivot-table update --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID \ + --properties '{ + "rows": [{"field": "部门"}], + "values": [ + {"field": "销售额", "summarize_by": "sum"}, + {"field": "订单号", "summarize_by": "count", "display_name": "订单数"} + ], + "show_row_grand_total": true + }' +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --pivot-table-id string 透视表 ID (必填,可通过 pivot-table list 获取) + --properties string 透视表配置 JSON (必填) +``` + +### 删除透视表 +``` +Usage: + dws sheet pivot-table delete [flags] +Example: + dws sheet pivot-table delete --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --pivot-table-id string 透视表 ID (必填,可通过 pivot-table list 获取) +``` + +> [强制] 危险操作:删除不可恢复。必须先向用户展示操作摘要并获得明确同意,用户同意后才加 `--yes` 执行。 + +## `--properties` JSON Schema 速查 + +**顶层字段**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `rows` | object[] | 否 | 行字段数组(分组维度),详见下方「rows/columns 字段项」表 | +| `columns` | object[] | 否 | 列字段数组(交叉维度),结构同 rows | +| `values` | object[] | 是 | 值字段数组(聚合指标,至少一项),详见下方「values 字段项」表 | +| `filters` | object[] | 否 | 筛选字段数组,详见下方「filters 字段项」表 | +| `show_row_grand_total` | boolean | 否 | 是否显示行总计,默认 true | +| `show_col_grand_total` | boolean | 否 | 是否显示列总计,默认 true | +| `show_subtotals` | boolean | 否 | 是否显示分类小计,默认 true | +| `repeat_row_labels` | boolean | 否 | 是否显示重复项标签,默认 false | +| `collapse` | object | 否 | 行字段折叠状态:字段名 -> 要折叠的项目列表,如 `{"部门": ["A组", "B组"]}` | + +**rows/columns 字段项**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `field` | string | 是 | 列名(表头文本),必须与数据源首行的列名完全匹配 | +| `display_name` | string | 否 | 显示名称(不传时使用 field 值) | + +**filters 字段项**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `field` | string | 是 | 列名(表头文本),必须与数据源首行的列名完全匹配 | +| `display_name` | string | 否 | 显示名称(不传时使用 field 值) | + +**values 字段项**: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `field` | string | 是 | 列名(表头文本),必须与数据源首行的列名完全匹配 | +| `summarize_by` | string | 否 | 聚合方式,默认 sum,详见下方枚举表 | +| `display_name` | string | 否 | 显示名称(不传时自动生成,如"求和 - 销售额") | +| `show_data_as` | string | 否 | 值显示方式:`normal`/`percent_of_row_total`/`percent_of_col_total`/`percent_of_grand_total` | + +**summarize_by 枚举值**: + +| 值 | 说明 | +|----|------| +| `sum` | 求和(默认) | +| `count` | 计数 | +| `average` | 平均值 | +| `max` | 最大值 | +| `min` | 最小值 | +| `product` | 乘积 | +| `count_numbers` | 数值计数 | +| `std_dev` | 标准偏差 | +| `std_dev_p` | 总体标准偏差 | +| `var` | 方差 | +| `var_p` | 总体方差 | +| `distinct` | 去重计数 | +| `median` | 中位数 | + +## `--source` 数据源格式 + +- 格式:`'SheetName'!StartCell:EndCell` +- 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500` +- 必须包含表头行(通常从第 1 行开始) +- sheet 名称用单引号包裹(含空格或特殊字符时必须) +- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构 + +## 高级功能示例 ```bash +# 含折叠 + show_data_as dws sheet pivot-table create --node \ - --source "'Data'!A1:D100" \ + --source "'Sheet1'!A1:E200" \ --properties '{ - "rows":[{"field":"部门"}], - "columns":[{"field":"月份"}], - "values":[{"field":"销售额","summarize_by":"sum"}], - "show_row_grand_total":true + "rows": [{"field": "部门"}], + "values": [ + {"field": "销售额", "summarize_by": "sum"}, + {"field": "订单数", "summarize_by": "count", "show_data_as": "percent_of_col_total"} + ], + "collapse": {"部门": ["A组"]}, + "show_row_grand_total": true }' ``` -可选参数: +> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。 -- `--target-sheet-id`:将透视表放到指定工作表;不传时由服务端创建目标工作表。 -- `--target-position`:目标起始单元格,例如 `B2`。 -- `--properties @pivot.json`:从文件读取配置。 - -`--source` 必须包含工作表前缀并覆盖表头和数据,例如 `"'Data'!A1:D100"`。创建时 `properties.values` 必须是非空数组。 - -## properties 结构 - -- `rows` / `columns` / `filters`:字段数组,每项至少包含非空 `field`。 -- `values`:聚合字段数组,每项至少包含 `field`;创建时至少一项。 -- `summarize_by`:`sum`、`count`、`average`、`max`、`min`、`product`、`count_numbers`、`std_dev`、`std_dev_p`、`var`、`var_p`、`distinct`、`median`。 -- `collapse`:字段折叠配置,可为对象或数组。 -- 显示选项:`show_row_grand_total`、`show_col_grand_total`、`show_subtotals`、`repeat_row_labels`。 - -## 更新 +## 核心工作流 ```bash -dws sheet pivot-table update --node --sheet-id \ - --pivot-table-id \ - --properties '{"show_subtotals":false}' +# -- 工作流 1: 创建简单分组汇总 -- + +# 1. 先查 sheetId +dws sheet list --node -f json + +# 2. 查看数据范围确认列名和边界 +dws sheet csv-get --node --sheet-id --range "A1:F5" + +# 3. 创建透视表(按部门统计销售额) +dws sheet pivot-table create --node \ + --source "'Sheet1'!A1:D100" \ + --properties '{ + "rows": [{"field": "部门"}], + "values": [{"field": "销售额", "summarize_by": "sum"}], + "show_row_grand_total": true + }' + +# 4. 验证创建结果(用 create 返回的 targetSheetId 查询) +dws sheet pivot-table list --node --sheet-id + +# -- 工作流 2: 多维度交叉分析 -- + +dws sheet pivot-table create --node \ + --source "'Sheet1'!A1:E200" \ + --properties '{ + "rows": [{"field": "部门"}, {"field": "产品"}], + "columns": [{"field": "季度"}], + "values": [ + {"field": "销售额", "summarize_by": "sum"}, + {"field": "订单号", "summarize_by": "count", "display_name": "订单数"} + ], + "show_row_grand_total": true, + "show_col_grand_total": true, + "show_subtotals": true + }' + +# -- 工作流 3: 更新透视表配置 -- + +# 先获取现有配置 +dws sheet pivot-table list --node --sheet-id --pivot-table-id + +# 修改后回写(增加一个值字段) +dws sheet pivot-table update --node --sheet-id --pivot-table-id \ + --properties '{ + "rows": [{"field": "部门"}], + "values": [ + {"field": "销售额", "summarize_by": "sum"}, + {"field": "利润", "summarize_by": "average", "display_name": "平均利润"} + ], + "show_row_grand_total": true + }' ``` -Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。 +## 上下文传递 -## 删除 +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `pivot-table list` | `pivotTables[].id` | 后续 update / delete 的 `--pivot-table-id` | +| `pivot-table list --pivot-table-id` | 完整配置(rows/columns/values/filters/collapse/options) | update 时作为基础配置修改后回写 | +| `pivot-table create` | `pivotTable.id` | 后续 update / delete 的 `--pivot-table-id` | +| `pivot-table create` | `pivotTable.targetSheetId` | 后续 list / update / delete 的 `--sheet-id`(透视表所在工作表) | +| `pivot-table delete` | `message` | 确认删除完成 | +| `sheet list` | 工作表的 `sheetId` | 所有 pivot-table 命令的 `--sheet-id` | -```bash -dws sheet pivot-table delete --node --sheet-id \ - --pivot-table-id --yes -``` +## 注意事项 -删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。 +- [强制] **`--sheet-id` 获取规范**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) +- [强制] **创建后必须验证**:透视表创建后必须调用 `pivot-table list` 验证配置是否正确 +- [强制] **pivot-table-id 禁止臆测**:必须通过 `pivot-table list` 获取真实的透视表 ID,不可编造 +- [强制] **source 必须精确**:数据源范围必须从表头行开始,精确覆盖数据区域,先用 `csv-get` 确认数据边界 +- **field 名称必须准确**:rows/columns/values/filters 中的 field 值必须与源数据表头完全一致(区分大小写) +- **透视表自动新建子表**:创建的透视表默认放置在自动新建的子表中,不会覆盖源数据。可通过 `--target-sheet-id` 和 `--target-position` 指定放置到已有工作表 +- **不支持修改数据源**:update 仅可修改字段配置和显示选项,不可修改 source +- **折叠状态**:collapse 字段用于控制行字段的展开/折叠,格式为 {字段名: [要折叠的项]} +- **大 JSON 用 @file**:`--properties` 支持 `@文件路径` 读取本地 JSON 文件 diff --git a/skills/mono/references/products/sheet/sheet-range-operations.md b/skills/mono/references/products/sheet/sheet-range-operations.md index af6c076d..54bc630e 100644 --- a/skills/mono/references/products/sheet/sheet-range-operations.md +++ b/skills/mono/references/products/sheet/sheet-range-operations.md @@ -22,6 +22,11 @@ - 自动填充 → `range fill` - 请勿用 `range read` 读取源数据后手动计算规律再 `range update` 写入,`range fill` 支持服务端智能填充 +用户说"批量清除/批量操作/一次执行多个写操作/原子批量/先清除再写入": +- 批量清除多个区域 → `range batch-clear` +- 组合多个不同写操作 → `batch-update` +- 详见 [sheet-batch-operations](./sheet-batch-operations.md) + 用户说"复制区域/把这块数据复制到/复制到另一个工作表": - 复制区域 → `range copy-to` - 跨工作表 → `range copy-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"` @@ -82,11 +87,9 @@ Flags: --sheet-id string 工作表 ID 或名称 (必填) --source-range string 源数据范围,A1 表示法 (必填) --target-range string 目标填充范围,A1 表示法 (必填) - --fill-type string 填充类型: 不传则自动检测 / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值) + --fill-type string 填充类型: series(序列,默认) / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值) ``` -`--fill-type` **不传时自动检测**(根据源数据智能判断:数值序列递增、日期递增、文本复制等),这是填序列的默认行为,无需显式传值。`--help` 枚举的可选值只有 `copy` / `onlystyle` / `withoutstyle`;服务端另外也接受 `series`(强制按序列填充),但它不在 `--help` 列表里,一般用不到——要序列递增直接不传 `--fill-type` 即可。 - 目标范围须与源范围在行或列维度对齐(不支持对角填充)。 ### 复制区域 @@ -144,6 +147,8 @@ Flags: |------|-------------|------| | `list` | 工作表的 `sheetId` | range clear / range sort / range fill / range copy-to / range move-to 的 --sheet-id | +> **批量操作**(`range batch-clear` / `batch-update`)已拆分至 [sheet-batch-operations](./sheet-batch-operations.md),含写入边界 + 回读校验规范、典型组合场景示例等。 + ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 @@ -153,3 +158,4 @@ Flags: - ★ **排序用 `range sort` 不用 `range read` + 客户端排序 + `range update`**:服务端原子操作,支持多级排序 - ★ **排序前必须 `range read` 前几行判断表头**:读取排序范围前 3-5 行,对比首行与后续行的数据模式(类型、语义)来判断是否有表头。禁止不读就排,表头被排入数据不可撤销 - ★ **填充用 `range fill` 不用 `range read` + 手动计算 + `range update`**:服务端智能填充,支持序列递增、公式扩展等 +- ★ **批量操作详见 [sheet-batch-operations](./sheet-batch-operations.md)**:`range batch-clear` 多区域清除、`batch-update` 组合写操作,均原子事务 diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md index 03d52d67..0e2ae054 100644 --- a/skills/mono/references/products/sheet/sheet-read-data.md +++ b/skills/mono/references/products/sheet/sheet-read-data.md @@ -3,43 +3,34 @@ ## 使用场景 用户说"读数据/看表格内容": -- 需要列名、二维数据、dtype 和 number format,或准备与 `table-put` 配套处理 → `table-get` - 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护) +- 需要 dataframe/table 结构(列名、二维 data、pandas-style dtypes、number formats)→ `table-get` - 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read` +- 需要校验公式写入结果 → 先 `range read --value-render-option formula` 回读公式文本,再用 `formula-verify` 聚合扫描错误;需要确认业务数值时用 `raw_value` 抽样对账。完整流程见 [sheet-formula](./sheet-formula.md) - 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息 ## 命令选择 | 读取目的 | 推荐命令 | 说明 | |---------|---------|------| -| DataFrame 风格结构化读取 | `table-get` | 返回 columns / data / dtypes / formats,可与 table-put 配套 | | 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 | +| 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 | | 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation | | 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) | | 查看单元格级超链接 | `range read` | 返回 per-cell 结构,含 hyperlink;富文本片段链接仍在 richText 内 | | 查看公式文本 | `range read --value-render-option formula` | value 返回公式 | +| 公式写后结果校验 | `range read --value-render-option formula` + `formula-verify` + `raw_value` | 先确认公式文本,再聚合扫描错误,最后按需抽样对账业务数值 | | 获取原始值(数字/布尔而非格式化字符串) | `range read --value-render-option raw_value` | value 返回原始类型 | | 查看合并单元格范围 | `sheet info` | 返回 `mergedRanges`,这是工作表结构信息,不属于单元格值读取 | +## 与现有读取能力的区别 + +- `csv-get` 面向快速浏览和大表纯值读取:返回 CSV 文本、真实行号/列号映射和截断标记,token 低;不表达列类型、number format 或 per-cell 元数据 +- `table-get` 面向 dataframe/table 数据交换:返回 `columns` / `data` / `dtypes` / `formats`,适合后续结构化分析、类型保持和 `table-put` 回写;不返回合并单元格、dataValidation、hyperlink、richText、per-cell `cellStyles` +- `range read` 面向精确单元格读取:返回二维 per-cell 对象,可看公式、原始值、dataValidation、hyperlink、richText、cellStyles;适合少量或中等范围的细节检查,但大范围读取 token 成本更高 + ## 命令详细参考 -### 读取结构化 table 数据 -``` -Usage: - dws sheet table-get [flags] # 别名: dws sheet table-read -Example: - dws sheet table-get --node - dws sheet table-get --node --sheet-id --range "A1:D20" - dws sheet table-get --node --sheet-id --no-header -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 - --range string 读取范围,A1 表示法;可带 sheet 前缀 - --no-header 首行不作为表头,自动生成 col1/col2/... -``` - -`table-get` 面向结构化数据处理,返回 `columns`、二维 `data`、`dtypes` 和可用时的 `formats`。未传 `--sheet-id` / `--range` 时由服务端读取目标文档中的 table 数据;大表仍应通过 `--range` 限定范围。需要单元格样式、公式、数据验证或富文本细节时,继续使用 `range read`。 - ### 以 CSV 格式读取工作表数据(推荐) ``` Usage: @@ -77,6 +68,46 @@ Flags: - 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` …… - 单次建议 ≤5000 单元格 +### 以 table/dataframe 协议读取结构化数据 +``` +Usage: + dws sheet table-get [flags] +Example: + dws sheet table-get --node + dws sheet table-get --node --sheet-id --range "A1:D20" + dws sheet table-get --node --sheet-id "Sheet1" --no-header + dws sheet table-get --node --range "Sheet1!A1:D20" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称;默认不指定 + --range string 读取范围,A1 表示法;可带 sheet 前缀,如 Sheet1!A1:D10;默认读取非空范围 + --no-header 首行不作为表头,自动生成 col1/col2/...;默认 false +``` + +**默认行为**: +- 不传 `--sheet-id` / `--range` 时,读取文档内所有工作表 +- `--range` 带 sheet 前缀时,以 `range` 中的 sheet 为读取目标 +- 不传 `--range` 时,每个目标工作表读取实际非空范围,并裁掉前导全空行/列;显式传 `--range` 时按用户范围原样读取 +- 默认首行作为表头;传 `--no-header` 时不消费表头行,并生成 `col1` / `col2` / ... +- 大表或多 sheet 文档建议显式传 `--sheet-id` 和 `--range`,避免一次返回所有工作表造成超时或 token 过大 + +**返回字段说明**: +- `sheets` — 工作表数组;每项表示一个 table 结果 +- `name` / `sheetId` — 工作表名称与 ID +- `range` — 实际读取范围,A1 表示法 +- `columns` — 列名数组;默认来自第一行,传 `--no-header` 时生成 `col1` / `col2` / ... +- `data` — 二维数组;默认不含表头行。空单元格 / 缺失值在 table 语义上是 `null`;部分返回链路中也可能表现为空对象 `{}`,此时 `{}` 只表示空位,不是 JSON object 单元格值 +- `dtypes` — 按列返回 pandas-style dtype,例如 `object` / `float64` / `bool` / `datetime64[ns]`。普通文本列返回 `object`,当前不返回 `string` +- `formats` — 按列返回 number format code,例如 `@` / `#,##0.00` / `yyyy-mm-dd` + +**适用边界**: +- 不传 `--range` 时读取实际非空范围;如需控制数据量,请显式传 `--range` 分批读取 +- 首行作为表头时,表头必须非空且不重复;没有稳定表头时传 `--no-header` +- `data[][]` 中的 `{}` 表示空位 / 空单元格,按 `null` 处理;不要把它理解成可写入的对象值 +- `table-get` 返回的是值级 table 结构,不返回合并单元格、dataValidation、hyperlink、richText 或 per-cell `cellStyles`。这些信息请使用 `sheet info` 或 `range read` +- `dtypes` / `formats` 是列级推断结果;单列混合类型通常按 `object` 处理。即使 `table-put` 写入时声明 `string`,文本列回读也通常是 `object` +- 大整数/长数字 ID 如果在 `table-get` 中回读为 `float64`,不要把它当作逐位精确值使用;超过 `9007199254740991` 的整数必须在写入时按文本保存,回读应表现为字符串 / `object`,必要时用 `range read --value-render-option raw_value` 复核 + ### 读取工作表数据(per-cell 结构化信息) ``` Usage: @@ -107,7 +138,7 @@ Flags: | `dataValidation` | object | 仅有数据验证时出现,无则省略 | 数据验证配置,见下表 | | `hyperlink` | object | 仅有单元格级超链接时出现,无则省略 | 整格超链接,结构为 `{type, link, text?}`,见下表 | | `richText` | object | 仅富文本单元格出现 | 富文本结构(含超链接、附件、图片、样式片段等),普通纯文本不含此字段 | -| `cellStyles` | object | 仅有显式设置的样式时出现;MCP 序列化层也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 | +| `cellStyles` | object | 仅有显式设置的样式时出现;部分返回链路也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 | `range read` / `range get` 不返回合并单元格结构。要看合并单元格,请先或另行调用 `dws sheet info --node --sheet-id --format json`,使用其中的 `mergedRanges`。 @@ -174,7 +205,7 @@ Flags: } ``` -说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:MCP 平台序列化会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。 +说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:部分返回链路会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。 **取值模式说明**: | 模式 | value 返回内容 | 适用场景 | @@ -183,6 +214,8 @@ Flags: | `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 | | `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 | +**公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。 + **超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: - 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 - 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… @@ -219,5 +252,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 - `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` -- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表 +- `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` +- ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 +- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表 - ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确 diff --git a/skills/mono/references/products/sheet/sheet-search-replace.md b/skills/mono/references/products/sheet/sheet-search-replace.md index 61a3db16..9e04f3f4 100644 --- a/skills/mono/references/products/sheet/sheet-search-replace.md +++ b/skills/mono/references/products/sheet/sheet-search-replace.md @@ -13,6 +13,7 @@ - 查找替换 → `replace` - 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell` - 正则替换 → `replace --use-regexp` +- 替换公式文本(改公式源码而非显示值) → `replace --match-formula` - 删除匹配内容 → `replace --replacement ""` - 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数 @@ -68,6 +69,7 @@ Flags: --match-case 区分大小写 (默认 false) --match-entire-cell 完整单元格匹配 --use-regexp 启用正则表达式匹配 + --match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值) --include-hidden 包含隐藏行/列 ``` diff --git a/skills/mono/references/products/sheet/sheet-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md index bfa4bc1f..20cb2b32 100644 --- a/skills/mono/references/products/sheet/sheet-style-format.md +++ b/skills/mono/references/products/sheet/sheet-style-format.md @@ -6,7 +6,7 @@ | 方式 | 命令 / 字段 | 适用场景 | 粒度 | |------|------------|---------|------| -| **`set-style` / `batch-set-style`** | `dws sheet range set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等) | range 级别(2D 数组或全 range 统一值) | +| **`set-style` / `batch-set-style`** | `dws sheet range set-style` / `batch-set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等);多区域批量走 `batch-set-style`(服务端原子事务) | range 级别(2D 数组或全 range 统一值) | | **`cellStyles`**(`range update` 内) | `--values` 中每个 cell 的 `cellStyles` 字段 | 写值同时附带样式,少量 cell 一步到位 | per-cell 级别 | | **`style`**(richText 片段样式) | `--values` 中 richText 子项(`text`/`link`)的 `style` | 同一单元格内不同文字有不同字体样式 | 文本片段级别 | @@ -26,7 +26,8 @@ 用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号": - 仅设样式不改值 → `range set-style` -- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(内部顺序循环调 `update_range`) +- 多个区域刷同一组样式(可跨工作表)→ `range batch-set-style --ranges '["Sheet1!A1:B2","Sheet2!D1:D10"]' + 样式 flag`(一次原子提交) +- 各区域样式不同 → `range batch-set-style --batch ./styles.json`(同样一次原子提交) - 写值同时附带样式 → `range update --values` 中使用 `cellStyles` 字段(参见 sheet-write-data.md) - 请勿用 `range update --values` 写空/重写来模拟纯样式变更 @@ -59,6 +60,14 @@ Example: # 整片 range 启用自动换行 dws sheet range set-style --node --sheet-id --range "A1:E10" --word-wrap autoWrap + + # 斜体 + 下划线 + 指定字体(整个 range 共用) + dws sheet range set-style --node --sheet-id --range "A1:A10" \ + --font-style italic --font-line underline --font-family "微软雅黑" + + # 给 A1:D10 加四周实线边框(每格四边=网格线) + dws sheet range set-style --node --sheet-id --range "A1:D10" \ + --border-styles-json '{"top":{"style":"solid","color":"#000"},"bottom":{"style":"solid","color":"#000"},"left":{"style":"solid","color":"#000"},"right":{"style":"solid","color":"#000"}}' Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) @@ -77,26 +86,49 @@ Flags: --font-weights-json string 字体粗细二维 JSON 数组 --word-wrap string 换行方式:overflow/clip/autoWrap(整个 range 共用) --number-format string 数字格式代码,如 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss + --font-style string 字体样式:normal/italic(斜体,整个 range 共用) + --font-line string 字体线条:none/underline/line-through(下划线/删除线,单选,整个 range 共用) + --font-family string 字体族,如 Arial / 微软雅黑(整个 range 共用) + --border-styles-json string 四边边框 JSON,如 '{"top":{"style":"solid","color":"#000"},"bottom":{"style":"medium"}}' ``` **特性说明**: -- 每个样式维度提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致) -- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000 -- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center` 等 +- 每个可逐格样式维度(bg-color/font-size/h-align/v-align/font-color/font-weight)提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致) +- 整区共用的标量样式(无 `*-json` 形式):`--word-wrap` / `--number-format` / `--font-style` / `--font-line` / `--font-family` / `--border-styles-json` +- `--border-styles-json`:四边 `{top/bottom/left/right}`,每边 `{style, color?}`;`style` 取 `solid/medium/thick/dashed/dotted/double/hair/none` 等(**粗细已含在 style 内**:`solid`=细、`medium`=中、`thick`=粗,无独立 weight 字段);只传的边生效、未传的边保留原状(按边合并)。每条边**只接受 `style` / `color` 两个键**,写错的键(如 `colour`)或非字符串的 `color` 直接报错而不是静默忽略(否则会画出一条没有颜色的边框却报成功);**逐格应用**:对整个 range 每个单元格都画指定的边 = 网格效果(非仅最外框) +- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者 +- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000) +- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等 +- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并 -### 批量设置单元格样式 +### 批量设置单元格样式(服务端原子事务) ``` Usage: dws sheet range batch-set-style [flags] Example: + # 同一组样式刷多个区域(可跨工作表),一次原子提交 + dws sheet range batch-set-style --node \ + --ranges '["Sheet1!A1:B2","Sheet2!D1:D10"]' \ + --bg-color "#FFF2CC" --font-weight bold --font-family "微软雅黑" + + # 每项不同样式:用配置文件 dws sheet range batch-set-style --node --batch ./styles.json dws sheet range batch-set-style --node --batch ./styles.json --continue-on-error Flags: --node string 表格文档 ID 或 URL (必填) - --batch string 批次配置 JSON 文件路径 (必填) - --continue-on-error 遇到失败时继续执行后续条目(默认遇错即停) + --ranges string 目标区域 JSON 数组,每项须带工作表前缀,最多 100 项(与 --batch 二选一,同一组样式应用到所有区域) + --batch string 批次配置 JSON 文件路径(与 --ranges 二选一,每项可用不同样式) + --continue-on-error 遇到失败时继续执行其余项(默认严格事务,整批回滚) + (其余样式 flag 与 set-style 完全一致,配合 --ranges 使用) ``` +两种入口,二选一: + +| 入口 | 适用 | 说明 | +|------|------|------| +| `--ranges` | 同一组样式刷多个区域 | 对齐飞书 `+cells-batch-set-style`;每项须带工作表前缀(`"Sheet1!A1:B2"`),**支持跨工作表**,最多 100 项;样式用与 `set-style` 相同的 flag | +| `--batch ` | 各区域样式不同 | JSON 数组,每项含 `sheetId` + `range` + 该项自己的样式字段 | + 配置文件格式(JSON 数组,每个元素一条批次项): ```json [ @@ -109,6 +141,10 @@ Flags: "vAlign": "middle", "fontColor": "#333333", "fontWeight": "bold", + "fontStyle": "italic", + "fontLine": "underline", + "fontFamily": "Arial", + "borderStylesJson": "{\"top\":{\"style\":\"solid\",\"color\":\"#000\"}}", "wordWrap": "autoWrap", "numberFormat": "General" }, @@ -121,9 +157,11 @@ Flags: ``` **特性说明**: -- CLI 侧顺序循环逐条调用 `update_range`(非服务端批量),运行时输出 `[N/M]` 进度 -- 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法 -- 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误 +- CLI 把所有区域组装为**一次** `batch_update` 调用,由**服务端原子执行**:默认严格事务,任一项失败则**整批回滚**(不会出现部分区域已改、部分未改) +- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** +- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 +- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) +- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 ### 合并单元格 ``` @@ -219,7 +257,7 @@ Flags: ```bash # ── 工作流 4: 写入数据并设置样式 ── -# 1. 写入数据(每个单元格必须是 object;数字也写成字符串) +# 1. 写入数据 dws sheet range update --node --sheet-id --range "A1:C3" \ --values '[[{"type":"text","text":"商品"},{"type":"text","text":"单价"},{"type":"text","text":"数量"}],[{"type":"text","text":"苹果"},{"type":"text","text":"5.5"},{"type":"text","text":"100"}],[{"type":"text","text":"香蕉"},{"type":"text","text":"3.2"},{"type":"text","text":"200"}]]' --format json @@ -264,7 +302,9 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错 -- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式 +- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式 +- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式 +- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) - `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style` - `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 - `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) diff --git a/skills/mono/references/products/sheet/sheet-version.md b/skills/mono/references/products/sheet/sheet-version.md new file mode 100644 index 00000000..3a119745 --- /dev/null +++ b/skills/mono/references/products/sheet/sheet-version.md @@ -0,0 +1,69 @@ +# 历史版本 (version) + +## 使用场景 + +管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。 + +- 手动保存当前表格为一个版本快照 → `version save` +- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`) +- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认) + +三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。 + +## 命令详细参考 + +### 保存表格版本快照 +``` +Usage: + dws sheet version save [flags] +Example: + dws sheet version save --node SHEET_ID + +Flags: + --node string 表格文档 ID 或 URL (必填) +``` +手动为当前在线电子表格生成一个历史版本快照,便于后续查看或回滚。 + +### 查看表格历史版本列表 +``` +Usage: + dws sheet version list [flags] + dws sheet version ls [flags] +Example: + dws sheet version list --node SHEET_ID + dws sheet version list --node SHEET_ID --limit 10 + +Flags: + --node string 表格文档 ID 或 URL (必填) + --limit int 返回版本数量上限 (可选) + --cursor string 分页游标 (可选,游标分页) +``` +返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。 + +### 回滚表格到指定版本 +``` +Usage: + dws sheet version revert [flags] +Example: + dws sheet version revert --node SHEET_ID --version 3 --yes + +Flags: + --node string 表格文档 ID 或 URL (必填) + --version int 目标版本号 (必填,从 version list 获取) +``` +把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。 + +## 上下文传递 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 | +| `version save` | 版本快照结果 | 确认已生成快照 | +| `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 | + +## 注意事项 + +- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` +- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号 +- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可 +- 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成" diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index 1d1006b1..54c4ca48 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -3,7 +3,8 @@ ## 使用场景 用户说"创建表格/新建电子表格": -- 创建表格文档 → `create` +- 创建空表格文档 → `create` +- 创建并写入初始数据(可选样式)→ `create-with-data`(`--values` / `--sheets` 必须给一个) 用户说"看工作表/有哪些工作表/表格结构": - 列出工作表 → `list` @@ -26,13 +27,15 @@ - 复制并指定名称 → `copy --name "副本名称"` - 复制并指定位置 → `copy --index N` +用户说"显示网格线/显示网格/恢复网格线": +- 显示网格线 → `show-gridline`(连续多次调用幂等,不报错) + +用户说"隐藏网格线/去掉网格/关闭网格线/展示模式/看板模式": +- 隐藏网格线 → `hide-gridline`(连续多次调用幂等,不报错) + 用户说"删除工作表/移除工作表/删掉这个Sheet": - 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认) -用户说"显示网格线/隐藏网格线/去掉单元格网格": -- 显示网格线 → `show-gridline` -- 隐藏网格线 → `hide-gridline` - ## 命令详细参考 ### 创建钉钉表格文档 @@ -51,6 +54,76 @@ Flags: > **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 +### 创建表格文档并写入初始数据 +``` +Usage: + dws sheet create-with-data [flags] +Example: + dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' + dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]}]' +Flags: + --name string 表格名称 (必填) + --folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId + --workspace string 目标知识库 ID + --values string 初始数据,二维 JSON 数组,写入默认工作表 (与 --sheets 二选一) + --sheets string 多工作表 typed table JSON (与 --values 二选一) + --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) +``` + +建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 + +**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): + +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 + `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; + `name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 + - 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功) + - **不接受 `sheetId`**:文档此刻还不存在,工作表只能用 `name` 指定 + - `columns` 为非空字符串数组、列名不可为空/重复(按 trim 后比较);`data` 每行长度须等于 `columns`,单元格只能是字符串/数字/布尔/null + - `dtypes` / `formats` 的键须是 `columns` 里的列名(服务端按列名查表,写错既不报错也不生效) + - 单表写入上限 30000 单元格(写表头时含表头行) + +**建表时一并应用样式**(`--styles`,顶层键对齐飞书 snake_case,列表项内字段兼容 camelCase;两级都拒绝未知键): + +```json +{"styles":[{"name":"表名", + "cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC","number_format":"@"}], + "row_sizes":[{"range":"1:1","type":"pixel","size":28}], + "col_sizes":[{"range":"A:D","type":"pixel","size":120}], + "cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]} +``` + +- 每项至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一 +- 配 `--sheets` 时 styles 的**项数/顺序/name 必须与子表一一对应**;配 `--values` 时只给 1 项(`name` 被忽略) +- 数据写入后按 `cell_styles` → `row_sizes` → `col_sizes` → `cell_merges` 顺序执行(**非原子**) +- `row_sizes.type`:`pixel`(需 `size`)/ `standard`(恢复默认行高)/ `auto`(按内容自适应) +- `col_sizes.type`:`pixel`(需 `size`)/ `standard`(恢复默认列宽)——与飞书一致,**列宽不提供 `auto`** +- `size` 必须是正整数(小数会被拒绝而不是静默取整,避免得到与配置不符的行高列宽);`row_sizes.range` 形如 `"1:3"`、`col_sizes.range` 形如 `"A:C"`,带多余字符一律拒绝 +- `merge_type` 取 `all` / `rows` / `columns` + +**行为要点**: +- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 +- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 +- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 + +示例: + +```bash +# 创建并写入初始数据(默认工作表,裸二维值) +dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' + +# 创建多个带数据的工作表 +dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' + +# 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽) +dws sheet create-with-data --name "带样式" --values '[["姓名","分数"],["张三","90"]]' \ + --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold","background_color":"#FFF2CC"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}],"col_sizes":[{"range":"A:B","type":"pixel","size":120}]}]}' +``` + +> `--folder` / `--workspace` 的 ID 格式约束与 `dws sheet create` 完全一致(只接受 UUID 格式的 `fileId` 或 alidocs URL)。 + ### 获取全部工作表列表 ``` Usage: @@ -70,13 +143,34 @@ Example: dws sheet info --node dws sheet info --node --sheet-id dws sheet info --node --sheet-id "Sheet1" + dws sheet info --node --sheet-id --include groups + dws sheet info --node --sheet-id --include row_heights,col_widths,hidden_rows,hidden_cols Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (不传则返回第一个工作表) + --include strings 可选扩展信息,逗号分隔;支持 groups / row_heights / col_widths / hidden_rows / hidden_cols / frozen ``` 返回字段中 `mergedRanges` 是当前工作表的合并单元格范围列表(A1 表示法,如 `["C7:D11"]`)。它属于工作表结构/布局元数据:读写单元格内容前,如需判断表头、分组标题、续写位置或避开合并冲突,应先看 `sheet info`,不要在 `range read` / `csv-get` 的单元格值里寻找合并信息。 +返回字段中 `frozenRowCount` / `frozenColumnCount` 是冻结行列数量:冻结总是从工作表顶部第 1 行、左侧第 A 列开始连续计算;`0` 表示未冻结。它们是工作表级元数据,默认随 `sheet info` 返回,不需要额外 `include`。 + +最后非空数据边界通过 `nonEmptyRange` 返回,字段均为 A1/UI 语义:`range` 是从 `A1` 到最后非空单元格的范围,`lastCell` 是最后非空单元格地址,`lastRow` 是 1-based 行号,`lastColumn` 是列字母。空表时 `nonEmptyRange` 为 `null`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`。 + +需要读取行列分组时,加 `--include groups`。返回字段: +- `rowGroups`:行分组列表,单项包含 `range`、`startRow`、`endRow`、`count`、`level`、`collapsed` +- `columnGroups`:列分组列表,单项包含 `range`、`startColumn`、`endColumn`、`count`、`level`、`collapsed` + +其中 `range` 使用 A1 行/列范围(如 `"3:7"` / `"C:F"`),起止行号为 1-based,列使用字母;`level` 是 1-based 展示层级,不返回 `depth`;`collapsed` 表示当前折叠状态。`range read` / `csv-get` 不返回 `mergedRanges`、冻结或分组等结构元数据。 + +需要读取行高、列宽、隐藏行列时,按需加对应 `--include` 值(可组合,逗号分隔)。返回字段: +- `--include row_heights` → `rowHeights`(逐条行高,单项 `{row, pixelSize}`,`row` 为 1-based)+ `defaultRowHeight`(默认行高像素) +- `--include col_widths` → `colWidths`(逐条列宽,单项 `{column, pixelSize}`,`column` 为列字母)+ `defaultColumnWidth`(默认列宽像素) +- `--include hidden_rows` → `hiddenRows`(隐藏行区间列表,单项 `{startRow, endRow}`,1-based,连续隐藏行合并为一个区间) +- `--include hidden_cols` → `hiddenCols`(隐藏列区间列表,单项 `{startColumn, endColumn}`,列字母,连续隐藏列合并为一个区间) + +注意:`rowHeights` / `colWidths` 只返回**显式设置过**尺寸的行列(未自定义的行列不出现,用 `defaultRowHeight` / `defaultColumnWidth` 兜底);隐藏行列即使未改尺寸也会出现在 `hiddenRows` / `hiddenCols`。`frozen` 也在 include 白名单中,但冻结字段本就默认返回,传 `frozen` 与否结果一致。 + ### 新建工作表 ``` Usage: @@ -112,16 +206,14 @@ Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) --name string 新名称,最长 100 字符,不能包含 / \ ? * [ ] : - --title string --name 的别名(兼容) --index int 新位置(从 0 开始) --hidden --hidden=true 隐藏,--hidden=false 取消隐藏 - --tab-color string 工作表标签颜色,Hex 如 #FF0000;传空字符串清除颜色 --frozen-row-count int 冻结行数,0 表示取消冻结 --frozen-column-count int 冻结列数,0 表示取消冻结 ``` -更新工作表名称、位置、隐藏状态、标签颜色、冻结行列。 -`--name`(别名 `--title`)/ `--index` / `--hidden` / `--tab-color` / `--frozen-row-count` / `--frozen-column-count` 至少提供一个;多个属性可同时传入,将在同一次请求中更新。 +更新工作表名称、位置、隐藏状态、冻结行列。 +`--name` / `--index` / `--hidden` / `--frozen-row-count` / `--frozen-column-count` 至少提供一个;多个属性可同时传入,将在同一次请求中更新。 注意: - 至少需要保留一个可见的工作表,不能将所有工作表都隐藏 @@ -169,17 +261,33 @@ Flags: - 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除) - 不能删除最后一个可见工作表(至少保留一个可见工作表) -### 显示或隐藏网格线 +### 显示工作表网格线 ``` Usage: + dws sheet show-gridline [flags] +Example: dws sheet show-gridline --node --sheet-id - dws sheet hide-gridline --node --sheet-id + dws sheet show-gridline --node --sheet-id "Sheet1" Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) ``` -这两个命令只修改指定工作表的网格线显示状态,不修改单元格内容或边框样式。执行后可用同一命令反向恢复。 +### 隐藏工作表网格线 +``` +Usage: + dws sheet hide-gridline [flags] +Example: + dws sheet hide-gridline --node --sheet-id + dws sheet hide-gridline --node --sheet-id "Sheet1" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) +``` + +切换子表网格线显隐;二态语义在命令名里,无需额外参数(同 `update --hidden` / `update --hidden=false` 的隐藏/显示工作表模式)。 +网格线默认显示;隐藏后工作表背景为纯白色,适合截图、演示、仪表盘/看板场景(不影响打印和数据)。 +连续多次 show 或多次 hide 均为幂等操作,不会报错。 ## 核心工作流 @@ -192,13 +300,16 @@ dws sheet create --name "销售数据" --format json # 2. 查看工作表列表 — 提取 sheetId dws sheet list --node --format json -# 3. 写入表头和数据(每个单元格必须是 object;数字也写成字符串) +# 3. 写入表头和数据 dws sheet range update --node --sheet-id --range "A1:C1" \ --values '[[{"type":"text","text":"姓名"},{"type":"text","text":"部门"},{"type":"text","text":"销售额"}]]' --format json dws sheet range update --node --sheet-id --range "A2:C4" \ --values '[[{"type":"text","text":"张三"},{"type":"text","text":"销售部"},{"type":"text","text":"50000"}],[{"type":"text","text":"李四"},{"type":"text","text":"市场部"},{"type":"text","text":"38000"}],[{"type":"text","text":"王五"},{"type":"text","text":"销售部"},{"type":"text","text":"62000"}]]' --format json +# 4. 独立回读实际写入范围 +dws sheet range read --node --sheet-id --range "A1:C4" --format json + # ── 工作流 2: 读取已有表格数据 ── # 1. 获取工作表列表 @@ -218,7 +329,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f # 1. 新建工作表 dws sheet new --node --name "汇总" --format json -# 2. 在新工作表中写入汇总公式(公式写在 text,以 = 开头) +# 2. 在新工作表中写入汇总公式 dws sheet range update --node --sheet-id --range "A1:B1" \ --values '[[{"type":"text","text":"指标"},{"type":"text","text":"数值"}]]' --format json @@ -233,7 +344,7 @@ dws sheet range update --node --sheet-id --range "A2:B2 | `create` | `nodeId` | list / info / new / range read / range update / find 的 --node | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | | `new` | 新工作表的 `sheetId` | range read / range update / find 的 --sheet-id | -| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、判断合并单元格结构 | +| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` / `frozenRowCount` / `frozenColumnCount` / `rowGroups` / `columnGroups` / `rowHeights` / `colWidths` / `hiddenRows` / `hiddenCols` | 确定数据范围、追加写入起始行、判断合并单元格结构、回读冻结与行列分组、回读行高列宽与隐藏行列 | ## 注意事项 @@ -257,4 +368,6 @@ dws sheet range update --node --sheet-id --range "A2:B2 - `delete-sheet` 为不可逆操作,执行前必须向用户确认 - `delete-sheet` 不能删除隐藏的工作表,需先通过 `update --hidden=false` 取消隐藏再删除 - `delete-sheet` 不能删除最后一个可见工作表,至少保留一个可见工作表 +- `show-gridline` / `hide-gridline` 为幂等写操作:连续调用同一命令不会报错,适合 Agent 不确定当前状态时直接调用 +- `show-gridline` / `hide-gridline` 仅控制网格线视觉显示,不影响数据、打印、冻结行列等任何其他属性 - ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读) diff --git a/skills/mono/references/products/sheet/sheet-write-data.md b/skills/mono/references/products/sheet/sheet-write-data.md index 81944993..eaa5e695 100644 --- a/skills/mono/references/products/sheet/sheet-write-data.md +++ b/skills/mono/references/products/sheet/sheet-write-data.md @@ -3,12 +3,18 @@ ## 使用场景 用户说"写数据/填表/更新单元格/写入公式": -- 从 DataFrame/结构化对象一次写入一个或多个工作表 → `table-put` - 更新数据 → `range update` +- 写入公式前先读 [sheet-formula](./sheet-formula.md);写完必须回读公式文本并运行 `formula-verify`,关键业务数值还要抽样对账,不能只看写入返回成功 - 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等 - 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`) - **批量 CSV 值/公式写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 不需要超链接、dataValidation、cellStyles 或 richText;(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,字段值以 `=` 开头时按公式解析,并支持自动扩容 +用户说"写入结构化 table/dataframe/带列类型和格式的数据/跨 sheet 写入表格": +- 写入结构化 table → `table-put` +- `table-put` 的 `--sheets` 是 table spec JSON,支持一次写入一个或多个工作表;适合有明确 `columns` / `data` / `dtypes` / `formats` 的数据 +- table spec 较长或来自文件时,优先用 `--sheets @table.json`,避免 shell 转义长 JSON 出错 +- 如果只是一段 CSV 文本且没有 dtype/format 需求,用 `csv-put` 更简单(可含 `=` 开头的公式);如果需要超链接、dataValidation、richText 或 cellStyles,用 `range update` + 用户说"追加数据/添加行/在末尾加数据/新增记录": - 追加数据 → `append` @@ -17,38 +23,34 @@ - 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量值或公式写入,但不支持富格式对象 - 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加 +**与现有写入能力的区别**: + +- `range update` 面向精确单元格对象写入:适合少量公式、超链接、dataValidation、richText、少量 cellStyles 和 `{}` 跳过;必须自己提供 `--sheet-id`、`--range` 和维度完全匹配的二维 cell 对象 +- `append` 面向简单追加行:自动定位到末尾,只写原始值,不支持样式、公式、超链接或数据验证 +- `csv-put` 面向大批量 CSV 值/公式导入:输入 CSV 文本,写入指定起点;字段值以 `=` 开头时按公式解析,前加单引号时写入以 `=` 开头的字面文本;不保留 dtype/format 协议,也不支持富格式 +- `table-put` 面向 dataframe/table 数据交换:输入 `columns` / `data` / `dtypes` / `formats`,可一次写多个 sheet,适合和 `table-get` 往返;不支持 dataValidation、hyperlink、richText、附件或图片 + **四种写入命令能力对比**: -| 能力 | `table-put` | `range update` | `append` | `csv-put` | -|------|-------------|----------------|----------|-----------| -| 多工作表结构化写入 | 支持 | 不支持 | 不支持 | 不支持 | -| columns / dtypes / formats | 支持 | 不支持 | 不支持 | 不支持 | -| 公式(`=` 开头) | 按 dtype/底层能力 | 支持 | 不支持 | 支持;前导单引号写入以 `=` 开头的字面文本 | -| 单元格级超链接(`hyperlink`) | 不使用此命令 | 支持 | 不支持 | 不支持 | -| 富文本(片段链接/附件/图片) | 不使用此命令 | 支持 | 不支持 | 不支持 | -| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 | 支持 | -| 自动定位末尾 | mode=append | 不支持 | 支持 | 不支持 | -| 自动扩容行列 | 支持 | 不支持 | 支持 | 支持 | +| 能力 | `range update` | `append` | `csv-put` | `table-put` | +|------|---------------|----------|-----------|-------------| +| 公式(`=` 开头) | 支持 | 不支持 | 支持;前导单引号写入以 `=` 开头的字面文本 | 不作为公式协议;公式请用 `range update` 或 `csv-put` | +| 单元格级超链接(`hyperlink`) | 支持 | 不支持 | 不支持 | 不支持 | +| 富文本(片段链接/附件/图片) | 支持 | 不支持 | 不支持 | 不支持 | +| richText 片段样式(bold/color) | 支持 | 不支持 | 不支持 | 不支持 | +| `cellStyles`(背景色/字号/对齐等 cell-level 样式) | 支持 | 不支持 | 不支持 | 支持基础 cell-level 样式 | +| `{}` 跳过(保留原值) | 支持 | 不适用 | 不适用 | 不支持,按 table 矩阵写入 | +| `dataValidation`(下拉/复选框) | 支持 | 不支持 | 不支持 | 不支持 | +| 原始值(纯数字/字符串/布尔/null) | 支持 | 支持 | 支持 | 支持 | +| 列级 dtype / number format | 手工写 cellStyles | 不支持 | 自动识别为主 | 支持 `dtypes` / `formats` | +| 自动定位末尾 | 不支持 | 支持 | 不支持 | `mode:"append"` 支持 | +| 自动扩容行列 | 不支持 | 支持 | 支持 | 支持 | +| 跨 sheet 一次写入 | 不支持 | 不支持 | 不支持 | 支持 `sheets[]` | + +公式写入与校验的完整流程见 [sheet-formula](./sheet-formula.md)。写公式后先回读 `formula` 模式确认公式文本,再运行 `formula-verify` 聚合扫描错误;需要确认业务数值时继续回读 `raw_value` 抽样对账。 ## 命令详细参考 -### 写入结构化 table 数据 -``` -Usage: - dws sheet table-put [flags] # 别名: dws sheet table-write -Example: - dws sheet table-put --node \ - --sheets '[{"name":"Data","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]' - - dws sheet table-put --node --sheets @table.json - cat table.json | dws sheet table-put --node --sheets - -Flags: - --node string 表格文档 ID 或 URL (必填) - --sheets string sheet table JSON、@文件路径 或 - 表示 stdin (必填) -``` - -`--sheets` 接受 JSON 数组、`{"sheets":[...]}` 包装对象或单个 sheet 对象,CLI 会统一转换为非空数组。每个 sheet 至少需要 `columns`,并提供 `sheetId` 或 `name`;`name` 必须非空且少于 31 个字符。写入后必须用 `table-get` 回读验证。`table-get/table-put` 不支持放入 `sheet batch-update`。 - ### 更新工作表指定区域内容 ``` Usage: @@ -80,7 +82,7 @@ Flags: --values string 单元格内容,二维 JSON 数组 (必填);每个元素必须是 object:{type:text,text:...}、{type:richText,texts:[...]}、{dataValidation:...}、{cellStyles:...}、{hyperlink:...} 或 {}(详见下文 values 参数格式说明) ``` -**合并单元格注意(`range update`)**:这里说的是 `range update` 写入单元格对象这一路径,不是所有写入命令的统一行为。目标范围与已有合并区域冲突时,MCP 服务端会拦截并返回 `MERGED_CELLS_CONFLICT` 错误,错误消息中通常会列出具体冲突的合并区域地址。收到此错误时按以下流程处理: +**合并单元格注意(`range update`)**:这里说的是 `range update` 写入单元格对象这一路径,不是所有写入命令的统一行为。目标范围与已有合并区域冲突时,服务端会拦截并返回 `MERGED_CELLS_CONFLICT` 错误,错误消息中通常会列出具体冲突的合并区域地址。收到此错误时按以下流程处理: 1. 从错误消息中获取冲突的合并区域地址(如 `A1:B2, C3:D4`),或通过 `dws sheet info --node --sheet-id --format json` 查询完整的合并区域列表(`mergedRanges` 数组) 2. 用 `dws sheet unmerge-cells --range <冲突区域>` 取消这些合并 3. 执行 `range update` 写入数据 @@ -94,6 +96,8 @@ Flags: **范围职责**:`range update` 负责写入单元格内容(原始值/公式/富文本对象),并支持通过 `cellStyles` 附带 per-cell 样式。如需批量设置整片区域的样式(不写值),请使用 `dws sheet range set-style`。 +**公式校验要求**:公式用 `{"type":"text","text":"=..."}` 写入。写完后先执行 `range read --value-render-option formula` 确认公式文本,再执行 `formula-verify` 聚合扫描错误;关键业务结果继续用 `range read --value-render-option raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。 + ### 在工作表末尾追加数据 ``` Usage: @@ -116,14 +120,12 @@ Flags: Usage: dws sheet csv-put [flags] Example: - # 内联多行 CSV:必须用 $'...' 让 \n 变成真换行;普通单引号 '...\n...' 里的 \n 是字面量,会写成一行含字面 \n 的错误数据 dws sheet csv-put --node --sheet-id --start-cell A1 \ - --csv $'name,score\nAlice,95\nBob,87' + --csv 'name,score\nAlice,95\nBob,87' dws sheet csv-put --node --sheet-id --start-cell A1 \ --csv "=1+1,'=1+1" - # 多行数据推荐用 @文件,最稳妥 dws sheet csv-put --node --sheet-id --start-cell B2 \ --csv @data.csv --allow-overwrite @@ -143,7 +145,7 @@ Flags: 将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。 - **分隔符必须是英文逗号 `,`**(ASCII 0x2C),禁止使用中文逗号 `,`(U+FF0C)。中文逗号不会被识别为分隔符,会导致整行被写入同一个单元格。生成 CSV 内容时务必检查分隔符 - 写入值和公式,不支持样式/批注。字段值以 `=` 开头时默认按公式解析;如需写入以 `=` 开头的字面文本,在字段值前加单引号(例如 `'=1+1`) -- 数字/日期/百分数由表格引擎自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期) +- 数字/日期/百分数由表格系统自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期) - 自动扩容行列:CSV 数据超出当前工作表维度时自动追加行/列 - 与 `range update` 不同,目标区域如含合并单元格,`csv-put` 会打散合并并写入 CSV 数据 - 若需要保留原有合并结构,写入前先用 `sheet info` 记录 `mergedRanges`,写入后用 `merge-cells` 恢复对应区域 @@ -152,6 +154,121 @@ Flags: - CSV 文本上限 2M 字符,单元格总数上限 30000 - 特殊字符处理:CLI 会自动过滤 `\r`(Windows 换行符)和 BOM(UTF-8 文件头标记),Excel/Windows 导出的 CSV 可直接使用;如 CSV 数据中含零宽字符(U+200B 等)或 Bidi 控制符,CLI 会拒绝并报错 +### 写入结构化 table 数据 +``` +Usage: + dws sheet table-put [flags] +Example: + dws sheet table-put --node \ + --sheets '[{"name":"Sheet1","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"},"formats":{"score":"0"}}]' + + dws sheet table-put --node --sheets @table.json + cat table.json | dws sheet table-put --node --sheets - +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheets string sheet table JSON、@文件路径 或 - 表示 stdin (必填) +``` + +`--sheets` 支持三种形态: +```json +[ + {"name":"Sheet1","columns":["name"],"data":[["Alice"]]} +] +``` +```json +{ + "sheets": [ + {"name":"Sheet1","columns":["name"],"data":[["Alice"]]} + ] +} +``` +```json +{"name":"Sheet1","columns":["name"],"data":[["Alice"]]} +``` + +**单个 sheet spec 字段**: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `name` | 与 `sheetId` 二选一 | 工作表名称。名称已存在时写入已有 sheet;不存在时创建同名 sheet | +| `sheetId` | 与 `name` 二选一 | 工作表 ID;传入时优先于 `name`,不会按 `name` 创建新 sheet | +| `startCell` | 否 | overwrite 起始单元格,或 append 起始列;默认 `A1` | +| `mode` | 否 | `overwrite`(默认)或 `append` | +| `header` | 否 | 是否写入表头。overwrite 默认 true;append 默认 false;append 到空表且未显式指定时写表头 | +| `allowOverwrite` | 否 | 是否允许覆盖已有值。默认 true;显式传 false 时,目标区域已有值会报错 | +| `columns` | 是 | 有序列名数组;不可为空、不可重复、不可包含空列名 | +| `data` | 否 | 行优先二维数组,默认 `[]`;每行宽度必须等于 `columns.length`;值支持 string / number / boolean / null。写入空位建议传 `null`;若复用 `table-get` 结果,`data[][]` 中的 `{}` 表示空位 / 空单元格,按 `null` 对待 | +| `dtypes` | 否 | 按列名指定 pandas-style dtype;默认 `{}`,未指定列按 object/string 写入。普通文本建议声明 `object`;`string` 会按文本写入,但 `table-get` 回读通常仍是 `object` | +| `formats` | 否 | 按列名指定 number format code;默认 `{}`,系统按 dtype 补日期/文本默认格式 | +| `cellStyles` | 否 | 基础 cell-level 样式,默认不设置样式;支持二维样式矩阵,或按列名指定样式对象。字段范围与下文 `cellStyles 子结构` 一致 | + +**完整示例**: +```json +{ + "sheets": [ + { + "name": "销售", + "startCell": "A1", + "mode": "overwrite", + "allowOverwrite": true, + "columns": ["日期", "金额", "渠道"], + "data": [ + ["2026-07-06", 1500.5, "门店"], + ["2026-07-07", 2300.75, "线上"] + ], + "dtypes": { + "日期": "datetime64[ns]", + "金额": "float64", + "渠道": "object" + }, + "formats": { + "日期": "yyyy-mm-dd", + "金额": "¥#,##0.00" + }, + "cellStyles": { + "金额": {"numberFormat": "¥#,##0.00"} + } + } + ] +} +``` + +**dtype 与 format 规则**: +- `datetime64[ns]` / `date` 按日期写入;日期值建议使用 ISO 字符串(如 `2026-07-06`),未传 format 时默认 `yyyy-mm-dd` +- `bool` / `boolean` 按布尔写入 +- `int*` / `uint*` / `float*` / `complex*` 按数值写入;读回时数值列通常统一表现为 `float64` +- `object` 是普通文本列的推荐 dtype;`string` 等其他 dtype 按字符串列处理,未传 format 时默认文本格式 `@`。读回时文本列通常表现为 `object`,不要期待返回 `string` +- `formats` 使用表格 number format code,含义与 `range set-style --number-format` 一致 +- 大整数精度保护:超过 `9007199254740991` 的整数、长数字 ID、订单号、手机号、SKU 等需要逐位精确的值,必须按文本写入。`table-put` 中即使把值写成字符串,只要 dtype 声明为 `int64` / `uint64` / `float64`,链路仍会按 number 处理并可能丢精度;应使用字符串值 + `object` dtype + 文本格式 `@` + +**大整数/长数字 ID 示例**: +```json +{ + "name": "订单", + "columns": ["order_id", "amount"], + "data": [["9007199254740993", 128.5]], + "dtypes": {"order_id": "object", "amount": "float64"}, + "formats": {"order_id": "@", "amount": "#,##0.00"} +} +``` + +**返回字段说明**: +- `sheets` — 写入结果数组,每项对应一个 sheet spec +- `name` / `sheetId` — 实际写入的工作表名称与 ID +- `range` — 实际写入范围 +- `dataRows` — 写入的数据行数,不含表头 +- `columns` — 写入列数 +- `writes` — 实际写入次数 +- `mode` — 实际写入模式,`overwrite` 或 `append` + +**限制与边界**: +- `table-put` 支持跨 sheet:一个 `sheets[]` 可包含多个 sheet spec +- 单次 table 写入单元格数上限 30000,计算包含表头行;超过上限需拆分 +- 大整数和长数字标识符不应声明为 `int64` / `uint64`。例如 `9007199254740993` 按数值写入会回读为 `9007199254740992`;要保精度必须写成 `"9007199254740993"`,并把该列声明为 `object` 且设置 `formats: {"列名":"@"}` +- `table-put` 不支持 dataValidation、hyperlink、richText、附件、单元格图片;这些能力用 `range update` / `write-image` +- 样式能力以 `cellStyles` 基础 cell-level 样式为准;复杂结构样式、合并单元格、行高列宽请另用 `range set-style` / `merge-cells` / `update-dimension` +- 写入后需要用 `table-get` 回读 `columns` / `data` / `dtypes` / `formats` 校验;`table-get` 不返回 `cellStyles`,需要校验 table 写入样式时用 `range read`;需要校验合并或结构元数据时用 `sheet info` + ## values 参数格式说明 `range update` 只接受 `--values` 一个数据参数,为二维 JSON 数组,第一维为行,第二维为列。每个 cell 是以下之一: @@ -187,7 +304,7 @@ Flags: `hyperlink` 作用于整个单元格,适合“这个单元格整体可点击跳转”的场景。它和 richText 的片段级 `link` 不同。 > **hyperlink 三种语义**: -> - **不传 `hyperlink` 字段** → 保留原超链接(引擎自动 readback 回写) +> - **不传 `hyperlink` 字段** → 保留原超链接(系统自动保留) > - **`hyperlink: {"type":"none"}`** → 显式清除单元格超链接 > - **`hyperlink: {"type":"path"/"sheet"/"range", link, text?}`** → 写新超链接(覆盖) > @@ -208,7 +325,7 @@ Flags: 注意: - 不传 `hyperlink` 字段同于 “保留原超链接”,无需先 read 再回传 -- Agent 调用统一使用 `hyperlink: {type:"none"}` 清除超链接;底层 REST 兼容 `hyperlink:null`,但 MCP schema / 网关可能过滤 null 字段,不要把 null 当默认写法 +- Agent 调用统一使用 `hyperlink: {type:"none"}` 清除超链接;不要把 `hyperlink:null` 当默认写法,避免空字段在调用链路中被过滤后语义不清 - `hyperlink` 可以不带 `type/text` cell 单独出现,用于只设置或清理链接并保留原值 - 不要把 `hyperlink` 和 `type:"richText"` 混用;整格链接用 `hyperlink`,片段链接用 richText 子项 `type:"link"` @@ -341,7 +458,7 @@ dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:C1" \ ### 重要约束 -- 不再支持 `{type:"number"}` / `{type:"boolean"}` / `{type:"null"}` —— MCP `complexValues` 仅接受 `text` / `richText` 两种 type,或 `{}` 跳过。数字 / 布尔走 `{type:"text","text":"<字符串形式>"}` +- 不再支持 `{type:"number"}` / `{type:"boolean"}` / `{type:"null"}` —— 当前单元格对象协议仅接受 `text` / `richText` 两种 type,或 `{}` 跳过。数字 / 布尔走 `{type:"text","text":"<字符串形式>"}` - 不支持直接传入原始值(字符串、数字、布尔、null、空字符串);`null` 不等同于 `{}`,`null` 会报错 - 维度必须与 `--range` 范围完全一致,例如 `--range "A1:B3"` 需要 3 行 2 列的数组 - 清理整格超链接使用 `{"hyperlink":{"type":"none"}}`;不要使用 `{"hyperlink":null}` 作为 agent 默认调用形态 @@ -366,6 +483,9 @@ dws sheet range update --node --sheet-id --range "A1:C1" \ dws sheet range update --node --sheet-id --range "A2:C4" \ --values '[[{"type":"text","text":"张三"},{"type":"text","text":"销售部"},{"type":"text","text":"50000"}],[{"type":"text","text":"李四"},{"type":"text","text":"市场部"},{"type":"text","text":"38000"}],[{"type":"text","text":"王五"},{"type":"text","text":"销售部"},{"type":"text","text":"62000"}]]' --format json +# 4. 独立回读实际写入范围 +dws sheet range read --node --sheet-id --range "A1:C4" --format json + # ── 工作流 4: 写入数据并设置样式 ── # 1. 写入数据 @@ -407,12 +527,13 @@ dws sheet append --node --sheet-id \ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `create` | `nodeId` | list / info / new / range update / append / csv-put 的 --node | -| `list` | 工作表的 `sheetId` | range update / append / csv-put 的 --sheet-id | -| `new` | 新工作表的 `sheetId` | range update / append / csv-put 的 --sheet-id | +| `create` | `nodeId` | list / info / new / range update / append / csv-put / table-put 的 --node | +| `list` | 工作表的 `sheetId` | range update / append / csv-put 的 --sheet-id;table-put 的 sheetId | +| `new` | 新工作表的 `sheetId` | range update / append / csv-put 的 --sheet-id;table-put 的 sheetId | | `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、识别合并单元格结构 | | `append` | `a1Notation` 追加数据所在范围 | 确认追加位置 | | `csv-put` | `a1Notation` 实际写入的单元格范围 | 确认写入位置和范围 | +| `table-put` | `sheets[].sheetId` / `sheets[].range` / `sheets[].dataRows` | 确认实际写入的 sheet、范围和行数;再用 `table-get` 回读类型与格式 | ## 注意事项 @@ -423,14 +544,17 @@ dws sheet append --node --sheet-id \ - 维度不足请按行 / 列补齐为同等大小;不需要修改的位置用 `{}` 跳过(保留原值),需要清空的位置用 `{"type":"text","text":""}`;禁止出现各行列数不一致或与 `--range` 不匹配的情况,否则调用会直接报错 - 如需写整格超链接,把 `{"type":"text","text":"...","hyperlink":{"type":"path","link":"..."}}` 放进 `--values` 二维数组对应的单元格里;富文本片段链接才使用 richText 子项 `type:"link"` - ★ **清空区域优先用 `range clear`(强制)**:需要清空整片区域时必须使用 `range clear`,禁止用 `range update` 模拟。仅在 `range update` 写入混合数据时个别 cell 需要清空,才在 `--values` 中用 `{"type":"text","text":""}` -- ★ **不再支持 `{type:"number"}` / `{type:"boolean"}` / `{type:"null"}`(强制)**:MCP `complexValues` 仅接受 `type:"text"` 与 `type:"richText"` 两种,CLI 会在本地直接拦截非法 type 并报错。写数字 / 布尔请用 `{"type":"text","text":"<字符串形式>"}`(服务端按内容自动识别),不要再用旧的 `value` 字段 -- **dataValidation 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"dropdown"/"checkbox",...}`=覆盖。无需先 read 再回传,引擎自动保留原 DV +- ★ **不再支持 `{type:"number"}` / `{type:"boolean"}` / `{type:"null"}`(强制)**:当前单元格对象协议仅接受 `type:"text"` 与 `type:"richText"` 两种,CLI 会在本地直接拦截非法 type 并报错。写数字 / 布尔请用 `{"type":"text","text":"<字符串形式>"}`(服务端按内容自动识别),不要再用旧的 `value` 字段 +- **dataValidation 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"dropdown"/"checkbox",...}`=覆盖。无需先 read 再回传,系统会保留原 DV - **hyperlink 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"path"/"sheet"/"range",...}`=覆盖。Agent 调用不要使用 `hyperlink:null` - ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) - ★ **大批量 CSV 值/公式写入用 `csv-put` 不用 `range update`**:当数据可表达为 CSV(可含 `=` 开头的公式)、不需要超链接或富文本对象且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。它无需构造二维 JSON 数组并支持自动扩容。需要单元格级超链接、富文本对象、样式、数据验证、`{}` 跳过,或仅更新少量单元格时使用 `range update` +- ★ **公式写完必须分层校验**:先用 `range read --value-render-option formula` 确认公式文本,再用 `formula-verify` 聚合扫描错误;若返回 `partial` / `hasMore=true` 必须继续校验,关键业务数值还要用 `raw_value` 抽样对账 +- ★ **结构化 table 写入用 `table-put`**:需要列名、dtype、number format、跨 sheet specs 或 `table-get` 回写形态时使用 `table-put`;单次写入单元格数硬限 30000,包含表头 +- ★ **大整数/长数字 ID 必须按文本写(强制)**:超过 `9007199254740991` 的整数、订单号、手机号、SKU 等需要逐位精确的值,不要用 JSON number,也不要声明 `int64` / `uint64`。用字符串值 + `object` dtype + 文本格式 `@`;写后用 `table-get` 或 `range read --value-render-option raw_value` 回读确认 - `range update` 必填 `--values`;单元格级超链接通过 cell 的 `hyperlink` 字段表达,附件 / 图片 / 带样式片段通过 `--values` 内的 richText 富格式表达,CLI 不再有 `--hyperlinks` 参数 - `range update` 职责边界:`range update` 写入单元格内容(文本 / 公式 / 富文本对象),支持通过 `cellStyles` 附带 per-cell 样式(背景色 / 字号 / 对齐等)。但批量刷整片区域的统一样式时,应使用 `dws sheet range set-style`(如 "给表头加粗居中")或 `dws sheet range batch-set-style --batch `。两种方式各有适用场景:少量 cell 写值 + 样式一步到位用 `cellStyles`;大面积统一样式用 `set-style` - `append` 自动定位到最后一行有数据的位置下方插入,无需手动计算行号 - `append` 的 `--values` 二维数组中每行的列数必须一致,否则会报错。如果用户提供的数据中各行长度不同,必须先将短行用空字符串 `""` 补齐到与最长行相同的列数后再调用。追加的数据列数也应与工作表已有数据列数保持一致 - `append` vs `range update`:追加新行用 `append`,修改已有单元格用 `range update` -- ★ **`append` / `csv-put` 不支持 `{}` skip、`dataValidation`、富文本或超链接**:这些能力仅限 `range update`。`append` 只接受原始值;`csv-put` 接受 CSV 值和公式,字段值以 `=` 开头时按公式解析,前加单引号时写入以 `=` 开头的字面文本。需要下拉列表、富格式或跳过部分单元格时,必须使用 `range update` +- ★ **`append` / `csv-put` / `table-put` 不支持 `{}` skip、dataValidation、富文本、超链接**:这些能力仅限 `range update`。`append` 只接受原始值;`csv-put` 接受 CSV 值和公式;`table-put` 接受 table 矩阵、dtypes、formats 和基础 `cellStyles`。需要超链接、下拉列表、富文本或跳过部分单元格时,必须使用 `range update` diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 2f3c0b36..8e1e3b3b 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -75,7 +75,7 @@ metadata: - `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。 - 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。 - Webhook 使用 `+messages-send --as webhook --webhook-token `;不要退回原子 Webhook 命令。 -- 流式卡片不是普通消息内容,使用 `+messages-send-card`;当前只支持 streaming text create/update,不支持 Card JSON 或 callback。 +- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`,Runtime 把 create 返回前缀加到 `--content`;禁写占位符;仅 text。 ## 关键结果语义 @@ -93,7 +93,7 @@ metadata: | 场景 | Reference | |---|---| | 复杂发送、跨会话转发、共同群或组合流程 | [01-messaging.md](references/01-messaging.md) | -| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) | +| 编辑/撤回/引用/转发/卡片/reaction/Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) | | 建群、成员、管理员、群公告、群设置 | [chat-group.md](references/chat/chat-group.md) | | Bot 搜索、入群、群发和撤回 | [chat-bot.md](references/chat/chat-bot.md) | | 会话置顶、分类、红点、免打扰和隐藏 | [chat-conversation.md](references/chat/chat-conversation.md) | diff --git a/skills/multi/dingtalk-chat/references/card/create.md b/skills/multi/dingtalk-chat/references/card/create.md index 41a8ed33..e7a1fbed 100644 --- a/skills/multi/dingtalk-chat/references/card/create.md +++ b/skills/multi/dingtalk-chat/references/card/create.md @@ -7,9 +7,18 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传 - 只传目标:创建卡片并从真实结果取得 `bizId`,供后续更新。 - 同时传 `--content`:Runtime 串行执行 create → 从返回提取 `bizId` → update;默认 `--flow-status 3`。 +- 群聊可传 `--at-open-dingtalk-ids` 或 `--at-all`;艾特对象只进入初始 + `create_and_send_card`。同一次调用带 `--content` 时,Runtime 将 create 返回的 + `atTag` 自动加在正文前,再调用 `update_streaming_card`;调用方不要拼 ID + 或艾特占位符。 - `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。 创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续 `+messages-update-card` 或人工处理。 当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。 + +```bash +dws chat +messages-send-card --group --at-open-dingtalk-ids --content "请确认" +dws chat +messages-send-card --group --at-all --content "请大家确认" +``` diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 6a0d007d..9ebd0ceb 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -232,6 +232,22 @@ dws chat message search --query "发布计划" --start --end --page- | `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,并在 `--text` / `--content` 中二选一;可选 `--title` `--at-all` `--at-open-dingtalk-ids` | | `message read-status` | 查消息已读/未读状态 | `--group` `--message-id`;可选目标用户 | +刚由 `message send` 发出的消息会返回 `openTaskId`。先用 `message query-send-status` 查询,成功结果中的 `openMessageId` 和 `openConversationId` 可直接传给 `message edit` 或 `message recall`,无需再按消息内容从列表反查 ID。 + +```bash +# 1. 发送后保留 openTaskId +dws chat message send --group --text "原始内容" +# 2. 查询得到 openMessageId 和 openConversationId +dws chat message query-send-status --open-task-id +# 3. 编辑消息 +dws chat message edit --conversation-id --msg-id --text "更新后的内容" + +# 发送后撤回使用同一 ID 链 +dws chat message send --group --text "待撤回的内容" +dws chat message query-send-status --open-task-id +dws chat message recall --conversation-id --msg-id +``` + `+messages-recall` 与 `recall-by-bot` 不同:前者使用 `openMessageId`,缺少会话 ID 时先只读查询消息详情;后者撤回机器人消息,需要 `robot-code + processQueryKey`。不要把 `processQueryKey` 当 `openMessageId`。 编辑消息使用 `message edit`。推荐传 `--text`,CLI 会生成 markdown content JSON:`{"title":"标题","text":"正文"}`;可选 `--title`,不传时会从正文自动生成标题。高级场景可直接传 `--content`,此时必须是完整 markdown content JSON,且不能同时传 `--text`。 @@ -275,12 +291,14 @@ dws chat message reply --conversation-id --ref-msg-id --content "开始处理" --flow-status 1 +dws chat +messages-send-card --group --at-open-dingtalk-ids --content "开始处理" --flow-status 1 dws chat +messages-update-card --biz-id --content "更新的卡片内容" --flow-status 2 dws chat +messages-update-card --biz-id --content "最终内容" --flow-status 3 ``` `flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。 +群聊还可传 `--at-all`;两种艾特参数只随创建请求发送。send-card 同时带正文时, +Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID 或占位符。 当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见 [card references](../card/schema.md)。 diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index bc0ee825..c884f44a 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -10,7 +10,7 @@ |---------|---------| | 在线电子表格(`axls`) | 走 `sheet` 全部命令 | | `xlsx` / `xls` / `xlsm` / `csv` | `dws drive download --node --output <路径>` 下载到本地处理,禁止调用任何 `sheet` 子命令 | -| 本地 xlsx/xls 导入为在线表格 | `dws sheet import --file <路径>`(单命令一站式:创建会话→上传→确认→轮询,产出在线电子表格) | +| 本地 xlsx/xls 导入为在线表格 | `dws sheet import create --file <路径>`(单命令一站式:创建会话→上传→确认→轮询,产出在线电子表格;`sheet import` 保留兼容) | | 在线表格导出为 xlsx | `dws sheet export`(axls → xlsx 格式转换) | 用户贴原始 `alidocs` URL 时必须先 probe:`dws drive info --node --format json`,按 [链接规范](../../dingtalk-shared/references/url-patterns.md#alidocs-url-类型探测流程) 校验: @@ -29,7 +29,7 @@ ## Agent 编辑总则 1. **先定位再编辑**:未知 `sheet-id` 时必须先 `dws sheet list --node --format json`,禁止猜 `Sheet1` / `sheet1` / `0` / `default`。用户只给工作表名称时,也先用 `list` 确认真实名称或 ID。 -2. **先读结构再动结构**:合并、冻结、行列分组、行高列宽、隐藏行列、最后非空边界都属于工作表结构信息,先读 `dws sheet info --node --sheet-id --format json`。 +2. **先读结构再动结构**:合并、冻结、行列分组、行高列宽、隐藏行列、最后非空边界都属于工作表结构信息,先读 `dws sheet info --node --sheet-id --format json`;分组回读加 `--include groups`,行高、列宽和隐藏行列按需加 `--include row_heights,col_widths,hidden_rows,hidden_cols`。 3. **按目的选择读取方式**:快速看值和大表分批用 `csv-get`;需要 `columns` / `data` / `dtypes` / `formats` 用 `table-get`;需要公式、样式、数据验证、超链接、富文本等 per-cell 元数据用 `range read`。 4. **写返回不等于完成**:任何写操作完成后都要用独立读命令回读确认。值写入用 `csv-get` / `range read` / `table-get`,结构变更用 `sheet info`,对象类操作用对应 `list` / `get`。 5. **大批量 CSV 值/公式不要拼大 JSON**:超过 5 行或 20 个单元格、且不需要富格式对象时优先 `csv-put`;字段值以 `=` 开头按公式解析,前加单引号写入以 `=` 开头的字面文本;需要 table/dataframe 语义时用 `table-put`。 @@ -68,7 +68,7 @@ | Reference | 描述 | |-----------|------| -| [sheet-workbook](sheet/sheet-workbook.md) | 管理表格文档与工作表。当用户说"创建表格"、"有哪些工作表"、"新建/重命名/隐藏/冻结/复制/删除工作表"、"显示/隐藏网格线"时使用;`info` 可回读冻结行列等工作表结构信息。命令:`create`/`list`/`info`/`new`/`update`/`copy`/`delete-sheet`/`show-gridline`/`hide-gridline` | +| [sheet-workbook](sheet/sheet-workbook.md) | 管理表格文档与工作表。当用户说"创建表格"、"有哪些工作表"、"新建/重命名/隐藏/冻结/复制/删除工作表"、"显示/隐藏网格线"时使用;`info` 可回读冻结行列,`--include groups` 回读分组,其他行列结构按需使用对应 `--include`。命令:`create`/`list`/`info`/`new`/`update`/`copy`/`delete-sheet`/`show-gridline`/`hide-gridline` | | [sheet-read-data](sheet/sheet-read-data.md) | 读取工作表数据。当用户说"读数据"、"看表格内容"、"查看数据"时使用。推荐 `csv-get`(CSV 格式、token 低、防爆保护);需按 table/dataframe 结构读取列名、data、dtypes、formats 时用 `table-get`;需 value + dataValidation / hyperlink / richText / cellStyles 等 per-cell 元数据时用 `range read`。大范围数据建议分页读取(单次 ≤5000 单元格)。命令:`csv-get`/`table-get`/`range read` | | [sheet-write-data](sheet/sheet-write-data.md) | 写入数据到工作表。当用户说"写数据"、"填表"、"更新单元格"、"写公式"、"超链接"、"写值同时设样式/数据验证"、"追加数据"、"导入CSV"、"写入结构化 table"时使用。大批量 CSV 值/公式(>5行或>20单元格,且无需富格式)必须用 `csv-put` 而非 `range update`;结构化 table/dataframe 输入用 `table-put`。命令:`range update`/`append`/`csv-put`/`table-put` | | [sheet-formula](sheet/sheet-formula.md) | 公式写入、文本回读、错误聚合与结果抽样。当用户说"写公式"、"计算列"、"辅助列"、"总计/占比/增长率/查找计算"、"校验公式"、"检查公式错误"时使用。命令:`range update` / `csv-put` / `range read --value-render-option formula/raw_value` / `formula-verify` | @@ -82,8 +82,8 @@ | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | -| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` / `export get` | -| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试,禁止用 `drive upload` 代替。命令:`import`/`import get` | +| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` | +| [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` | | [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` | | [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` | | [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` | @@ -95,7 +95,7 @@ 1. **`--sheet-id` 禁止臆测**:未知时必须 `dws sheet list --node --format json` 查询,禁止编造 `Sheet1`/`sheet1`/`0`/`default` 2. **合并单元格是结构信息**:`dws sheet info --node --sheet-id --format json` 返回 `mergedRanges`(如 `["C7:D11"]`);不要在 `range read` / `csv-get` 里寻找合并信息 3. **冻结行列是工作表元数据**:`sheet info` 顶层返回 `frozenRowCount` / `frozenColumnCount`,分别表示从顶部第 1 行、左侧第 A 列开始冻结的数量;`0` 表示未冻结。不要在 `range read` / `csv-get` 里寻找冻结信息 -4. **结构变更后回读**:创建/取消分组、调整行高列宽或隐藏行列后,用 `dws sheet info --node --sheet-id --format json` 回读工作表结构确认。 +4. **结构变更后按需 include 回读**:创建/取消分组后,用 `dws sheet info --node --sheet-id --include groups --format json` 回读 `rowGroups` / `columnGroups`;行高、列宽、隐藏行列分别使用 `--include row_heights` / `col_widths` / `hidden_rows` / `hidden_cols`。裸 `sheet info` 不承诺返回这些可选结构字段。 5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn` 6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"` 7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV @@ -104,7 +104,7 @@ 10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension` 11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put` 12. **单元格图片用 `write-image`**(`range update` 不支持图片参数) -13. **`export` / `import` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);`export get` / `import get`为单次查询,非终态时可手动再次调用 +13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) 15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@` 16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update` @@ -116,7 +116,7 @@ - **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。 - **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。 - **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。 -- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理单元格评论(锚定 `--sheet-id`+`--range`,内容复用 `doc-comment` 服务、与前端评论同桶,单元格位置由 flex-table-app 承载并以 `customCategory`(cmt-*) 关联),详见 [sheet-comment](sheet/sheet-comment.md)。 +- **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。 - **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 ## URL 粘贴场景 @@ -132,16 +132,16 @@ ## 导入本地表格 -用户说"导入Excel/把xlsx转为在线表格/上传表格并在线编辑"时,用 `dws sheet import`(单命令一站式,详见 [sheet-import](./sheet/sheet-import.md)): +用户说"导入Excel/把xlsx转为在线表格/上传表格并在线编辑"时,用 `dws sheet import create`(单命令一站式;`sheet import` 保留兼容,详见 [sheet-import](./sheet/sheet-import.md)): ```bash # 导入本地表格文件为在线电子表格(默认表格名取文件名) -dws sheet import --file ./data.xlsx +dws sheet import create --file ./data.xlsx # 指定目标文件夹与导入后表格名 -dws sheet import --file ./report.xls --folder-token --name "月度报表" +dws sheet import create --file ./report.xls --folder-token --name "月度报表" # 导入到指定知识库 -dws sheet import --file ./data.xls --workspace +dws sheet import create --file ./data.xls --workspace ``` - 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格 - CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试** @@ -258,6 +258,7 @@ Flags: - 多维表 / 字段类型 → 切到 `dingtalk-aitable` - 把表数据写进文档 → 切到 `dingtalk-doc` + ## 局部意图与 Recipe - [局部意图消歧](sheet-intent-guide.md)。 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md b/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md index 08f7ba7e..8d922bec 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md @@ -5,7 +5,7 @@ `batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束: 1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / 工作表。规划 range 时先确认每个子操作的边界。 -2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,单元格值用 `range read` 或 `csv-get` 抽样回读受影响区域;合并、行列分组等结构元数据用 `sheet info` 回读校验。 +2. **批次完成后必须回读校验**:整个 `batch-update` 执行成功后,单元格值用 `range read` 或 `csv-get` 抽样回读受影响区域;合并用裸 `sheet info`,行列分组用 `sheet info --include groups` 回读校验。 3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `batch-update` 补齐,禁止交付半成品。 ## 使用场景 @@ -29,7 +29,7 @@ 用户说"批量创建行列分组/批量取消行列分组/同时调整尺寸并分组": - 连续行/列分组 → `group-dimension` - 取消连续行/列分组 → `ungroup-dimension` -- 分组回读用 `sheet info` +- 分组回读用 `sheet info --include groups` - `group-dimension` 在 batch 中只适合默认展开分组;需要创建后立即折叠时,用独立 `dws sheet group-dimension --group-state fold` **何时推荐使用 `batch-update`**: @@ -236,7 +236,7 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' ### 批量创建/取消行列分组 -分组属于工作表结构元数据,批次完成后用 `sheet info` 回读 `rowGroups` / `columnGroups`,不要用 `range read` 校验。回读项中的 `level` 是 1-based,`collapsed` 表示当前折叠状态。 +分组属于工作表结构元数据,批次完成后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups`,不要用裸 `sheet info` 或 `range read` 校验。回读项中的 `level` 是 1-based,`collapsed` 表示当前折叠状态。 ```jsonc [ @@ -263,7 +263,7 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `list` | 工作表的 `sheetId` | batch-clear 的 --ranges sheet 前缀 / batch-update 子操作 input 的 sheet-id | -| `batch-update` | 各子操作的执行结果 | 回读校验(值用 range read / csv-get;合并、分组等结构用 sheet info) | +| `batch-update` | 各子操作的执行结果 | 回读校验(值用 range read / csv-get;合并用 sheet info;分组用 sheet info --include groups) | ## 注意事项 @@ -273,5 +273,5 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' - ★ **table 写入不进 batch-update**:当前 `batch-update` 不支持结构化 table 写入;结构化 table 请直接调用 `dws sheet table-put` - ★ **批次完成后必须回读校验**:值写入用 `range read` 或 `csv-get` 抽样回读受影响区域;结构变更用 `sheet info` 回读 - ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update` -- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;分组结果用 `sheet info` 回读 +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;分组结果用 `sheet info --include groups` 回读 - `batch-update` 中 `group-dimension` 只适合默认展开分组;需要 `fold` 初始状态时使用独立 `group-dimension` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md b/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md index c28f538e..985e9813 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md @@ -11,8 +11,8 @@ - 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。 - 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。 -- `create` / `list` 为表格专属工具(`doc-comment` server 的 `create_sheet_comment` / `list_sheet_comments`);`reply` / `update` / `delete` 位置无关,复用 `doc-comment` server 的通用评论工具(`reply_comment` / `update_comment` / `delete_comment`)。五个命令统一走 `doc-comment` server。 -- 底层评论内容与文档评论同源存储(bizCode=DOCS),单元格位置由表格引擎(flex-table-app CP)承载,二者以 `cmt-{uuid}` 关联。 +- `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。 +- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。 --- diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md b/skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md index 751feec0..c039f548 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md @@ -14,7 +14,7 @@ 用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性": - 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false` -- 设置行高/列宽 → `update-dimension --pixel-size` +- 设置行高/列宽 → `update-dimension --pixel-size`;恢复默认尺寸 → `--size-type standard`;行高按内容自适应 → `--size-type auto`(列宽不提供 auto) - 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden` 用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行": @@ -29,7 +29,7 @@ 用户说"创建行分组/创建列分组/新建分组并折叠/新建分组并展开/取消分组": - 创建连续行/列分组 → `group-dimension` - 取消连续行/列分组 → `ungroup-dimension` -- 分组创建后用 `sheet info` 回读 `rowGroups` / `columnGroups` +- 分组创建后用 `sheet info --include groups` 回读 `rowGroups` / `columnGroups` - 仅创建分组时可用 `--group-state expand|fold` 指定初始展开/折叠;当前没有单独调整已有分组折叠状态的命令 - 用户要求"折叠/展开已有分组"时,当前不支持直接修改已有分组的 `collapsed` 状态,不能承诺可用 `group-dimension` 完成 @@ -117,6 +117,12 @@ Example: # 使用工作表前缀(忽略 --sheet-id) dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden + + # 恢复第 2~3 行为默认行高(无需 --pixel-size) + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "2" --length 2 --size-type standard + + # 第 2~3 行按内容自适应行高 + dws sheet update-dimension --node --sheet-id --dimension ROWS --start-index "2" --length 2 --size-type auto Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) @@ -125,14 +131,25 @@ Flags: --length string 更新数量,正整数 (必填),最大 5000 --hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一 --pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一 + --size-type string 尺寸模式: pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项) ``` 批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。 `--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。 支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。 -`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 +`--hidden` 与 `--pixel-size` 至少必须提供一个(或用 `--size-type standard/auto` 让服务端决定尺寸)。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。 +`--size-type` 尺寸模式(对齐飞书 row_sizes/col_sizes 的 type): + +| 值 | 行为 | ROWS | COLUMNS | 是否需要 --pixel-size | +|----|------|------|---------|----------------------| +| `pixel`(默认) | 使用 `--pixel-size` 指定的像素值 | 支持 | 支持 | 需要 | +| `standard` | 恢复为工作表默认行高/列宽 | 支持 | 支持 | 不需要 | +| `auto` | 按内容自适应行高 | 支持 | 不提供 | 不需要 | + +列宽不提供 `auto`(与飞书 `col_sizes` 的 type 枚举一致,只有 `pixel` / `standard`)。需要控制列宽时用 `--pixel-size` 指定,或用 `--size-type standard` 恢复默认列宽。 + ### 移动行或列 ``` Usage: @@ -205,7 +222,7 @@ Flags: 创建后用以下命令回读: ```bash -dws sheet info --node --sheet-id --format json +dws sheet info --node --sheet-id --include groups --format json ``` 回读字段为 `rowGroups` / `columnGroups`,单项包含 `range`、起止行列、`count`、`level`、`collapsed`。`collapsed=true` 表示当前分组折叠。 @@ -226,7 +243,7 @@ Flags: --range string 整行或整列范围,A1 表示法 (必填)。行如 "3:7",列如 "C:F" ``` -取消指定连续整行或整列范围上的分组。取消后同样用 `sheet info` 回读确认目标 `range` 已从 `rowGroups` / `columnGroups` 中移除。 +取消指定连续整行或整列范围上的分组。取消后同样用 `sheet info --include groups` 回读确认目标 `range` 已从 `rowGroups` / `columnGroups` 中移除。 `group-dimension` / `ungroup-dimension` 可以放进 `batch-update` 做原子组合;但 batch 中的 `group-dimension` 只适合默认展开分组。需要创建后立即折叠时,请使用独立 `dws sheet group-dimension --group-state fold`。 @@ -334,7 +351,7 @@ dws sheet update-dimension --node --sheet-id \ - `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 - `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,不接受普通单元格矩形 -- `group-dimension` 输出使用 `level`,且为 1-based;不要使用旧的 `depth` 字段 -- `sheet info` 是分组回读入口;`range read` / `csv-get` 不返回行列分组 +- `group-dimension` 输出和 `sheet info --include groups` 回读均使用 `level`,且为 1-based;不要使用旧的 `depth` 字段 +- `sheet info --include groups` 是分组回读入口;裸 `sheet info` 不承诺返回行列分组,`range read` / `csv-get` 不返回行列分组 - 当前不能直接调整已有分组的 `collapsed` 状态;`--group-state fold` 只在创建分组时生效 - `batch-update` 支持 `group-dimension` / `ungroup-dimension`,但不适合创建后立即折叠;需要 `fold` 时使用独立 `group-dimension` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md index 99b38f57..ba0880c6 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -11,6 +11,11 @@ - 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) - 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) +用户说"导出 CSV/存成 csv/导出这个工作表为 csv": +- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) +- 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式 +- 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理 + ## 命令详细参考 ### 导出表格为 xlsx(异步任务一站式) @@ -56,7 +61,50 @@ Flags: - 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 -**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 + +### 导出单个工作表为纯 CSV(同步) +``` +Usage: + dws sheet export-csv [flags] +Example: + dws sheet export-csv --node + dws sheet export-csv --node --sheet-id --output ./data.csv + dws sheet export-csv --node --range A1:Z1000 --value-render-option raw_value + +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称(不传则第一个工作表) + --range string 导出范围,A1 表示法(不传则整表;大表可用此分块导出) + --value-render-option string 取值模式: formatted_value(默认) / raw_value / formula + --output string 本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout + --allow-truncated 允许数据被截断时仍然导出。默认截断即报错且不写文件 +``` + +同步读取**单个**工作表并输出 RFC4180 CSV,不走异步导出任务。与 `export` 是两条独立命令:`export` 导整篇工作簿的 xlsx(异步提交+轮询),`export-csv` 只导一个工作表的纯值(一次请求即返回)。不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理。 + +**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。`--output` 指向已存在目录时保存为该目录下的 `sheet-export.csv`。 + +**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: +- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) +- 改用 `dws sheet export` 导出完整表格的 xlsx +- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格" + +```bash +# 落盘到本地 +dws sheet export-csv --node --sheet-id --output ./data.csv + +# 输出到 stdout 便于管道处理 +dws sheet export-csv --node --sheet-id + +# 大表分块导出(避免截断;不分块时默认会因截断而报错) +dws sheet export-csv --node --sheet-id --range "A1:Z1000" --output ./part1.csv + +# 明确接受不完整数据(否则截断即失败) +dws sheet export-csv --node --sheet-id --allow-truncated --output ./partial.csv +``` + +注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用 `dws sheet export` 导 xlsx。只是让 Agent 读取内容(带 `[row=N]` 行号前缀)请用 `dws sheet csv-get`。 ## 核心工作流 @@ -78,27 +126,22 @@ dws sheet export --node --output ./ # 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。 ``` -```bash -# ── 工作流 13: 导出超时后查询任务状态(手动兜底)── - -# 1. 执行导出(一体化命令,内部自动轮询约 5 分钟) -dws sheet export --node --format json -# 若超时,命令返回错误;等待一段时间后重新执行导出即可 -``` - ## 上下文传递 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `export` | `downloadUrl`(未指定 --output)/ `导出完成: `(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | -| `export` 超时中断 | 错误信息 | 告知用户稍后重试 | +| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | +| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 -- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` +- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` - ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 - `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 - `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 - 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export` +- ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-formula.md b/skills/multi/dingtalk-misc/references/sheet/sheet-formula.md index 5ff635e1..cf63e322 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-formula.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-formula.md @@ -30,7 +30,7 @@ | 查看格式化展示结果 | `range read` 或 `csv-get` 默认模式 | 用于用户肉眼看到的展示值检查 | | 扫描整本表格公式错误 | `formula-verify --node ` | 不传目标时扫描全部工作表的非空范围 | | 扫描单个工作表或范围 | `formula-verify --sheet-id ... [--range ...]` | `--range` 只传 A1 范围,不带工作表前缀 | -| 扫描多个目标 | `formula-verify --targets ...` | 每项为 `{"sheetId":"...","range":"..."}`,`range` 可省略 | +| 扫描多个目标 | `formula-verify --targets ...` | 必须是非空数组;每项为 `{"sheetId":"...","range":"..."}`,`range` 可省略 | ## 推荐流程 @@ -74,7 +74,7 @@ dws sheet formula-verify --node \ --format json ``` -`--targets` 也支持 `@targets.json` 和 `-`(stdin)。每项只允许 `sheetId` 和可选的 `range`;使用 `--targets` 时不能再传 `--sheet-id` 或 `--range`。 +`--targets` 也支持 `@targets.json` 和 `-`(stdin)。数组必须至少包含一个目标,不能传 `[]`;每项只允许非空 `sheetId` 和可选的字符串 `range`,`range` 只写 A1 范围且不能带工作表前缀。使用 `--targets` 时不能再传 `--sheet-id` 或 `--range`。 ### 扫描限制与自动化 @@ -183,6 +183,7 @@ dws sheet range read --node --sheet-id --range "D2:D5" \ - 把 `max-locations-per-error` 误解为错误计数上限;它只截断位置和样本。 - 看到 `status=partial` 或 `hasMore=true` 仍声称整本表公式零错误。 - 在 `--range` 中传 `Sheet1!A1:D10`,或把 `--targets` 与 `--sheet-id` 混用。 +- 显式传空的 `--targets '[]'`;这不是“无目标”,应改为至少一个目标,整本扫描则完全省略 `--targets`。 ## 关联文档 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-import.md b/skills/multi/dingtalk-misc/references/sheet/sheet-import.md index a03379c5..d50b27b4 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-import.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-import.md @@ -1,115 +1,76 @@ -# 导入 (import) +# 导入本地表格(import) ## 使用场景 -### 导入 +用户说“导入 Excel”“把 xlsx 变成在线表格”“上传本地表格并在线编辑”时,使用 Agent 可发现入口 `dws sheet import create`。它只新建在线电子表格,不覆盖已有表格。旧入口 `dws sheet import` 继续兼容人工调用。 -用户说"导入/上传本地表格/把 xlsx 变成在线表格/把 Excel 传成在线电子表格/供应商发来的报价单传成在线表格": -- 导入本地表格文件 → `import`(单命令一站式,内部自动完成创建会话、上传、确认、轮询) -- 需传 `--file` 与目标位置(`--folder-token` 或 `--workspace` 至少一个),`--name` 自定义表格名 -- 固定产出**在线电子表格**;只**新建**文档,不写入/覆盖已有表格 -- 支持格式:`xlsx` / `xls`(按扩展名识别);文件大小上限 20MB -- 禁止用 `drive upload` 代替:`drive upload` 只上传文件,不转换为可编辑的在线电子表格,语义不同且不在表格任务闭环内 -- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) -- 导入后如需 @同事,由 Agent 拿到 `documentUrl` 后再走 `chat`/`doc` 相关命令编排,本命令不负责通知 +- 支持本地 `xlsx` / `xls`,文件上限 20MB +- `--folder-token` 与 `--workspace` 至少提供一个 +- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试 +- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令 -## 命令详细参考 - -### 导入本地表格文件为在线电子表格(异步任务一站式) -``` -Usage: - dws sheet import [flags] # 一站式:创建会话 → 上传 → 确认 → 轮询 - dws sheet import get [flags] # 兜底:按 taskId 手动查询导入结果 -Example: - # 导入 xlsx 为在线电子表格(默认表格名取文件名) - dws sheet import --file ./quote.xlsx - - # 指定目标文件夹与导入后表格名称 - dws sheet import --file ./report.xls --folder-token --name "月度报表" - - # 导入到指定知识库 - dws sheet import --file ./data.xls --workspace - -Flags: - --file string 本地表格文件路径 (必填,支持 xlsx/xls) - --folder-token string 目标文件夹 ID 或 URL (与 --workspace 至少传一个) - --workspace string 目标知识库 ID 或 URL (与 --folder-token 至少传一个) - -n, --name string 导入后表格名称 (可选,默认取文件名去扩展名) -``` - -将本地表格文件(xlsx/xls)导入为一个新的钉钉在线电子表格,与 `dws sheet export`(导出)对称,构成表格"进出闭环"。**单命令一站式**:命令内部自动完成「创建导入会话 → 上传文件到 OSS → 确认导入 → 渐进式退避轮询」全流程,AI Agent 无需自行拆分步骤或实现轮询。 - -**内部流程**: -1. 调 `create_import_session` 获取 `sessionId` 与 OSS `uploadUrl`(入参 `fileName`/`suffix`/`fileSize`,可选 `targetFolderId`/`workspaceId`) -2. HTTP PUT 上传本地文件到 `uploadUrl` -3. 调 `confirm_import` 触发格式转换,获取 `taskId` -4. 按渐进式退避策略轮询 `query_import_task` 直至任务终态或超时 - -**内置轮询策略(CLI 内实现,无需关心)**: -- 第 1~5 次:每次间隔 2 秒 -- 第 6~10 次:每次间隔 5 秒 -- 第 11~20 次:每次间隔 10 秒 -- 第 21~30 次:每次间隔 15 秒 -- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令输出结构化 `timed_out:true` + `next_command` 并以成功退出(exit 0) - -**命令返回**: -- 成功:进度日志(`[1/4]`~`[4/4]`)+ 末尾 JSON,含 `success` / `taskId` / `documentUrl` / `documentName` / `documentType` / `nodeId` -- 超时:输出结构化 JSON,含 `success:false` / `timed_out:true` / `taskId` / `status:"processing"` / `next_command`(可直接复制执行的续查命令 `dws sheet import get --task-id `);命令以成功退出(exit 0),业务态由 `success:false` 表达,便于 Agent 编排续查 - -**失败处理(命令内部已处理,Agent 仅需转述)**: -- 转换返回失败状态:命令立即返回错误并附带原因,**禁止自动重试**,告知用户检查文件内容后再试 -- 轮询 30 次仍处理中:命令输出 `timed_out:true` + `next_command` 结构化字段(exit 0),Agent 检测到后按 `next_command` 用 `import get --task-id` 续查 - -**限制**:仅支持 xlsx/xls → 在线电子表格。导入为钉钉在线文档(md/docx 等 → 文字文档)请使用 `doc` 产品的 `import` 命令。 - -### 查询导入任务结果(兜底) -``` -Usage: - dws sheet import get [flags] -Example: - dws sheet import get --task-id - -Flags: - --task-id string 导入任务 ID (必填) -``` - -按 `taskId` 查询导入任务状态。通常不需要手动调用,`import` 会自动轮询到终态;仅在导入命令超时或中断后用于兜底查询。任务状态:`processing`(转换中)/ `completed`(成功,返回 documentUrl)/ `failed`(失败)。 - -## 核心工作流 +## 命令 ```bash -# ── 工作流 13: 导入本地表格文件为在线电子表格(单命令一站式)── +# 导入到指定文件夹 +dws sheet import create \ + --file ./quote.xlsx \ + --folder-token \ + --format json -# 场景 A:导入 xlsx,默认表格名取文件名(命令内部自动完成上传+转换+轮询) -dws sheet import --file ./quote.xlsx --format json +# 导入到指定知识库,并自定义名称 +dws sheet import create \ + --file ./report.xls \ + --workspace \ + --name "月度报表" \ + --format json -# 场景 B:导入 xls 到指定文件夹并自定义表格名 -dws sheet import --file ./report.xls --folder-token --name "月度报表" - -# 场景 C:导入到指定知识库 -dws sheet import --file ./data.xls --workspace - -# 场景 D:导入超时后兜底查询(用命令输出的 taskId) -dws sheet import get --task-id - -# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 -# 若命令返回失败或超时,按提示用 import get 查询,不要自动重调 import。 +# 导入超时或中断后续查 +dws sheet import get --task-id --format json ``` -## 上下文传递 +公开参数: -| 操作 | 从返回中提取 | 用于 | -|------|-------------|------| -| `import` | `documentUrl` / `nodeId`(导入成功后返回) | 下发给用户,或作为后续 `sheet` 操作的 `--node`;如需 @同事再走 `chat` 命令编排 | -| `import`(超时) | `timed_out` / `taskId` / `next_command`(结构化输出) | 检测到 `timed_out:true` 后执行 `next_command`(即 `import get --task-id`)续查 | -| `import get` | `status` / `documentUrl` | 判断任务是否完成并取回文档链接 | +| 命令 | 参数 | 说明 | +|------|------|------| +| `sheet import create` | `--file` | 本地 xlsx/xls 文件(必填) | +| `sheet import create` | `--folder-token` | 目标文件夹 ID 或 URL,与 `--workspace` 至少传一个 | +| `sheet import create` | `--workspace` | 目标知识库 ID 或 URL,与 `--folder-token` 至少传一个 | +| `sheet import create` | `--name`, `-n` | 导入后的名称;默认取文件名并去掉扩展名 | +| `sheet import get` | `--task-id` | 导入任务 ID | -## 注意事项 +## 返回与续查 -- ★ `import` 仅支持 xlsx/xls → 在线电子表格;导入为文字文档(md/docx 等)请用 `doc import` -- ★ `import` 只**新建**在线电子表格,不写入或覆盖已有表格;要往已有表格追加数据请用 `range update` / `range append` / `csv put` -- ★ `import` 为单命令一站式,CLI 内部已自动完成「创建会话 → 上传 → 确认 → 渐进式退避轮询」,**Agent 不得在外部实现轮询或重试** -- ★ 命令失败或超时时,**禁止自动重调 `import`**;超时会输出 `timed_out:true`,按 `next_command` 用 `import get --task-id` 续查,失败则告知用户检查文件后再试 -- 文件大小上限 20MB;空文件、目录、非白名单格式均会在上传前被 CLI 拦截报错 -- `--name` 未指定时默认取文件名去掉扩展名;`--folder-token` 与 `--workspace` 用于指定目标位置,至少提供一个,均不传会被 CLI 层拦截报错 -- 用户要求"导入表格/上传 xlsx 变在线表格"时,必须使用 `import`,禁止用 `drive upload`(只上传不转换,产出不可编辑,且不在表格闭环内) +成功时返回: + +```json +{ + "success": true, + "taskId": "", + "documentUrl": "", + "documentName": "月度报表", + "documentType": "1", + "nodeId": "" +} +``` + +轮询达到上限时命令以退出码 0 返回业务态,避免 Agent 重复创建文档: + +```json +{ + "success": false, + "timed_out": true, + "taskId": "", + "status": "processing", + "next_command": "dws sheet import get --task-id " +} +``` + +检测到 `timed_out:true` 后,只执行 `next_command` 续查。不要重新执行 `sheet import create`。 + +## 边界 + +- 本命令接受的是本地文件路径;它把 xlsx/xls 转换成新的 axls 在线表格 +- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `drive download` 下载,需要在线编辑时再执行 `sheet import create` +- 向已有在线表格写数据应使用 `range update`、`append`、`csv-put` 或 `table-put` +- md/doc/docx 等文字文档导入使用 `dws doc import` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md b/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md index 2073a4e5..6b8f93d2 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md @@ -191,7 +191,7 @@ Flags: - 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500` - 必须包含表头行(通常从第 1 行开始) - sheet 名称用单引号包裹(含空格或特殊字符时必须) -- MCP 内部自动将 source 解析为内部 sheetId + ISheetRange,CLI 直接透传即可 +- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构 ## 高级功能示例 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-search-replace.md b/skills/multi/dingtalk-misc/references/sheet/sheet-search-replace.md index 61a3db16..9e04f3f4 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-search-replace.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-search-replace.md @@ -13,6 +13,7 @@ - 查找替换 → `replace` - 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell` - 正则替换 → `replace --use-regexp` +- 替换公式文本(改公式源码而非显示值) → `replace --match-formula` - 删除匹配内容 → `replace --replacement ""` - 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数 @@ -68,6 +69,7 @@ Flags: --match-case 区分大小写 (默认 false) --match-entire-cell 完整单元格匹配 --use-regexp 启用正则表达式匹配 + --match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值) --include-hidden 包含隐藏行/列 ``` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md index da8e6aaa..20cb2b32 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md @@ -6,7 +6,7 @@ | 方式 | 命令 / 字段 | 适用场景 | 粒度 | |------|------------|---------|------| -| **`set-style` / `batch-set-style`** | `dws sheet range set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等) | range 级别(2D 数组或全 range 统一值) | +| **`set-style` / `batch-set-style`** | `dws sheet range set-style` / `batch-set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等);多区域批量走 `batch-set-style`(服务端原子事务) | range 级别(2D 数组或全 range 统一值) | | **`cellStyles`**(`range update` 内) | `--values` 中每个 cell 的 `cellStyles` 字段 | 写值同时附带样式,少量 cell 一步到位 | per-cell 级别 | | **`style`**(richText 片段样式) | `--values` 中 richText 子项(`text`/`link`)的 `style` | 同一单元格内不同文字有不同字体样式 | 文本片段级别 | @@ -26,7 +26,8 @@ 用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号": - 仅设样式不改值 → `range set-style` -- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(按配置顺序执行多条样式更新) +- 多个区域刷同一组样式(可跨工作表)→ `range batch-set-style --ranges '["Sheet1!A1:B2","Sheet2!D1:D10"]' + 样式 flag`(一次原子提交) +- 各区域样式不同 → `range batch-set-style --batch ./styles.json`(同样一次原子提交) - 写值同时附带样式 → `range update --values` 中使用 `cellStyles` 字段(参见 sheet-write-data.md) - 请勿用 `range update --values` 写空/重写来模拟纯样式变更 @@ -59,6 +60,14 @@ Example: # 整片 range 启用自动换行 dws sheet range set-style --node --sheet-id --range "A1:E10" --word-wrap autoWrap + + # 斜体 + 下划线 + 指定字体(整个 range 共用) + dws sheet range set-style --node --sheet-id --range "A1:A10" \ + --font-style italic --font-line underline --font-family "微软雅黑" + + # 给 A1:D10 加四周实线边框(每格四边=网格线) + dws sheet range set-style --node --sheet-id --range "A1:D10" \ + --border-styles-json '{"top":{"style":"solid","color":"#000"},"bottom":{"style":"solid","color":"#000"},"left":{"style":"solid","color":"#000"},"right":{"style":"solid","color":"#000"}}' Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) @@ -77,26 +86,49 @@ Flags: --font-weights-json string 字体粗细二维 JSON 数组 --word-wrap string 换行方式:overflow/clip/autoWrap(整个 range 共用) --number-format string 数字格式代码,如 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss + --font-style string 字体样式:normal/italic(斜体,整个 range 共用) + --font-line string 字体线条:none/underline/line-through(下划线/删除线,单选,整个 range 共用) + --font-family string 字体族,如 Arial / 微软雅黑(整个 range 共用) + --border-styles-json string 四边边框 JSON,如 '{"top":{"style":"solid","color":"#000"},"bottom":{"style":"medium"}}' ``` **特性说明**: -- 每个样式维度提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致) -- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000 -- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center` 等 +- 每个可逐格样式维度(bg-color/font-size/h-align/v-align/font-color/font-weight)提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致) +- 整区共用的标量样式(无 `*-json` 形式):`--word-wrap` / `--number-format` / `--font-style` / `--font-line` / `--font-family` / `--border-styles-json` +- `--border-styles-json`:四边 `{top/bottom/left/right}`,每边 `{style, color?}`;`style` 取 `solid/medium/thick/dashed/dotted/double/hair/none` 等(**粗细已含在 style 内**:`solid`=细、`medium`=中、`thick`=粗,无独立 weight 字段);只传的边生效、未传的边保留原状(按边合并)。每条边**只接受 `style` / `color` 两个键**,写错的键(如 `colour`)或非字符串的 `color` 直接报错而不是静默忽略(否则会画出一条没有颜色的边框却报成功);**逐格应用**:对整个 range 每个单元格都画指定的边 = 网格效果(非仅最外框) +- `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者 +- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000) +- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等 +- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并 -### 批量设置单元格样式 +### 批量设置单元格样式(服务端原子事务) ``` Usage: dws sheet range batch-set-style [flags] Example: + # 同一组样式刷多个区域(可跨工作表),一次原子提交 + dws sheet range batch-set-style --node \ + --ranges '["Sheet1!A1:B2","Sheet2!D1:D10"]' \ + --bg-color "#FFF2CC" --font-weight bold --font-family "微软雅黑" + + # 每项不同样式:用配置文件 dws sheet range batch-set-style --node --batch ./styles.json dws sheet range batch-set-style --node --batch ./styles.json --continue-on-error Flags: --node string 表格文档 ID 或 URL (必填) - --batch string 批次配置 JSON 文件路径 (必填) - --continue-on-error 遇到失败时继续执行后续条目(默认遇错即停) + --ranges string 目标区域 JSON 数组,每项须带工作表前缀,最多 100 项(与 --batch 二选一,同一组样式应用到所有区域) + --batch string 批次配置 JSON 文件路径(与 --ranges 二选一,每项可用不同样式) + --continue-on-error 遇到失败时继续执行其余项(默认严格事务,整批回滚) + (其余样式 flag 与 set-style 完全一致,配合 --ranges 使用) ``` +两种入口,二选一: + +| 入口 | 适用 | 说明 | +|------|------|------| +| `--ranges` | 同一组样式刷多个区域 | 对齐飞书 `+cells-batch-set-style`;每项须带工作表前缀(`"Sheet1!A1:B2"`),**支持跨工作表**,最多 100 项;样式用与 `set-style` 相同的 flag | +| `--batch ` | 各区域样式不同 | JSON 数组,每项含 `sheetId` + `range` + 该项自己的样式字段 | + 配置文件格式(JSON 数组,每个元素一条批次项): ```json [ @@ -109,6 +141,10 @@ Flags: "vAlign": "middle", "fontColor": "#333333", "fontWeight": "bold", + "fontStyle": "italic", + "fontLine": "underline", + "fontFamily": "Arial", + "borderStylesJson": "{\"top\":{\"style\":\"solid\",\"color\":\"#000\"}}", "wordWrap": "autoWrap", "numberFormat": "General" }, @@ -121,9 +157,11 @@ Flags: ``` **特性说明**: -- CLI 按配置顺序逐条执行样式更新(非原子事务),运行时输出 `[N/M]` 进度 -- 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法 -- 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误 +- CLI 把所有区域组装为**一次** `batch_update` 调用,由**服务端原子执行**:默认严格事务,任一项失败则**整批回滚**(不会出现部分区域已改、部分未改) +- 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** +- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 +- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) +- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 ### 合并单元格 ``` @@ -264,7 +302,9 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错 -- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式 +- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式 +- `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式 +- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) - `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style` - `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 - `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index fef1381c..54c4ca48 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -3,7 +3,8 @@ ## 使用场景 用户说"创建表格/新建电子表格": -- 创建表格文档 → `create` +- 创建空表格文档 → `create` +- 创建并写入初始数据(可选样式)→ `create-with-data`(`--values` / `--sheets` 必须给一个) 用户说"看工作表/有哪些工作表/表格结构": - 列出工作表 → `list` @@ -53,6 +54,76 @@ Flags: > **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 +### 创建表格文档并写入初始数据 +``` +Usage: + dws sheet create-with-data [flags] +Example: + dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' + dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]}]' +Flags: + --name string 表格名称 (必填) + --folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId + --workspace string 目标知识库 ID + --values string 初始数据,二维 JSON 数组,写入默认工作表 (与 --sheets 二选一) + --sheets string 多工作表 typed table JSON (与 --values 二选一) + --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) +``` + +建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 + +**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): + +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 + `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; + `name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 + - 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功) + - **不接受 `sheetId`**:文档此刻还不存在,工作表只能用 `name` 指定 + - `columns` 为非空字符串数组、列名不可为空/重复(按 trim 后比较);`data` 每行长度须等于 `columns`,单元格只能是字符串/数字/布尔/null + - `dtypes` / `formats` 的键须是 `columns` 里的列名(服务端按列名查表,写错既不报错也不生效) + - 单表写入上限 30000 单元格(写表头时含表头行) + +**建表时一并应用样式**(`--styles`,顶层键对齐飞书 snake_case,列表项内字段兼容 camelCase;两级都拒绝未知键): + +```json +{"styles":[{"name":"表名", + "cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC","number_format":"@"}], + "row_sizes":[{"range":"1:1","type":"pixel","size":28}], + "col_sizes":[{"range":"A:D","type":"pixel","size":120}], + "cell_merges":[{"range":"A1:B1","merge_type":"all"}]}]} +``` + +- 每项至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一 +- 配 `--sheets` 时 styles 的**项数/顺序/name 必须与子表一一对应**;配 `--values` 时只给 1 项(`name` 被忽略) +- 数据写入后按 `cell_styles` → `row_sizes` → `col_sizes` → `cell_merges` 顺序执行(**非原子**) +- `row_sizes.type`:`pixel`(需 `size`)/ `standard`(恢复默认行高)/ `auto`(按内容自适应) +- `col_sizes.type`:`pixel`(需 `size`)/ `standard`(恢复默认列宽)——与飞书一致,**列宽不提供 `auto`** +- `size` 必须是正整数(小数会被拒绝而不是静默取整,避免得到与配置不符的行高列宽);`row_sizes.range` 形如 `"1:3"`、`col_sizes.range` 形如 `"A:C"`,带多余字符一律拒绝 +- `merge_type` 取 `all` / `rows` / `columns` + +**行为要点**: +- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 +- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 +- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 + +示例: + +```bash +# 创建并写入初始数据(默认工作表,裸二维值) +dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' + +# 创建多个带数据的工作表 +dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' + +# 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽) +dws sheet create-with-data --name "带样式" --values '[["姓名","分数"],["张三","90"]]' \ + --styles '{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B1","font_weight":"bold","background_color":"#FFF2CC"}],"row_sizes":[{"range":"1:1","type":"pixel","size":28}],"col_sizes":[{"range":"A:B","type":"pixel","size":120}]}]}' +``` + +> `--folder` / `--workspace` 的 ID 格式约束与 `dws sheet create` 完全一致(只接受 UUID 格式的 `fileId` 或 alidocs URL)。 + ### 获取全部工作表列表 ``` Usage: @@ -72,6 +143,7 @@ Example: dws sheet info --node dws sheet info --node --sheet-id dws sheet info --node --sheet-id "Sheet1" + dws sheet info --node --sheet-id --include groups dws sheet info --node --sheet-id --include row_heights,col_widths,hidden_rows,hidden_cols Flags: --node string 表格文档 ID 或 URL (必填) @@ -85,7 +157,7 @@ Flags: 最后非空数据边界通过 `nonEmptyRange` 返回,字段均为 A1/UI 语义:`range` 是从 `A1` 到最后非空单元格的范围,`lastCell` 是最后非空单元格地址,`lastRow` 是 1-based 行号,`lastColumn` 是列字母。空表时 `nonEmptyRange` 为 `null`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn`。 -返回字段: +需要读取行列分组时,加 `--include groups`。返回字段: - `rowGroups`:行分组列表,单项包含 `range`、`startRow`、`endRow`、`count`、`level`、`collapsed` - `columnGroups`:列分组列表,单项包含 `range`、`startColumn`、`endColumn`、`count`、`level`、`collapsed` diff --git a/test/unit/mono_multi_skill_content_test.go b/test/unit/mono_multi_skill_content_test.go index 0e4c03d3..ec2ab5b5 100644 --- a/test/unit/mono_multi_skill_content_test.go +++ b/test/unit/mono_multi_skill_content_test.go @@ -1,7 +1,7 @@ // Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // -// Mono↔multi skill content QA (G1–G4). Spec: docs/skill-mono-multi-qa.md +// Mono↔multi skill content QA (G1–G5). Spec: docs/skill-mono-multi-qa.md // Contract: skills/content-qa/mono-multi-coverage.yaml package unit_test @@ -9,8 +9,11 @@ package unit_test import ( "bytes" "fmt" + "net/url" "os" "path/filepath" + "regexp" + "sort" "strings" "testing" @@ -41,10 +44,16 @@ type monoMultiQAContract struct { ExpectedMulti string `yaml:"expected_multi"` } `yaml:"omit_global"` PairedFiles []struct { + Mono string `yaml:"mono"` + Multi string `yaml:"multi"` + Mode string `yaml:"mode"` + LinkSubstitutions []monoMultiLinkSubstitution `yaml:"link_substitutions"` + } `yaml:"paired_files"` + PairedTrees []struct { Mono string `yaml:"mono"` Multi string `yaml:"multi"` Mode string `yaml:"mode"` - } `yaml:"paired_files"` + } `yaml:"paired_trees"` OrphanScriptsAllowlist []struct { Path string `yaml:"path"` Disposition string `yaml:"disposition"` @@ -53,6 +62,24 @@ type monoMultiQAContract struct { SkillsWithoutReferencesAllowlist []string `yaml:"skills_without_references_allowlist"` } +type monoMultiLinkSubstitution struct { + Mono string `yaml:"mono"` + Multi string `yaml:"multi"` +} + +type markdownLink struct { + Line int + Target string +} + +type markdownLinkIssue struct { + Link markdownLink + Resolved string + Err error +} + +var inlineMarkdownLinkPattern = regexp.MustCompile(`!?\[[^\]\n]*\]\(\s*(<[^>\n]+>|[^)\s\n]+)(?:\s+[^)\n]+)?\)`) + func loadMonoMultiQAContract(t *testing.T) (string, monoMultiQAContract) { t.Helper() root := repoRoot(t) @@ -255,13 +282,50 @@ func TestMonoMultiSkillContentG4Drift(t *testing.T) { if mode == "" { mode = "identical" } - switch mode { - case "identical": + if err := comparePairedFileContent(monoData, multiData, mode, pair.LinkSubstitutions); err != nil { + t.Errorf("G4: paired file mono %s vs multi %s: %v", pair.Mono, pair.Multi, err) + } + } + + for _, pair := range c.PairedTrees { + if pair.Mono == "" || pair.Multi == "" { + t.Fatalf("G4: paired tree requires mono and multi paths: %+v", pair) + } + mode := pair.Mode + if mode == "" { + mode = "identical" + } + if mode != "identical" { + t.Errorf("G4: unknown paired tree mode %q", mode) + continue + } + + monoDir := filepath.Join(root, "skills", "mono", filepath.FromSlash(pair.Mono)) + multiDir := filepath.Join(multiRoot, filepath.FromSlash(pair.Multi)) + monoFiles, err := readRelativeFileTree(monoDir) + if err != nil { + t.Errorf("G4: paired mono tree %s: %v", pair.Mono, err) + continue + } + multiFiles, err := readRelativeFileTree(multiDir) + if err != nil { + t.Errorf("G4: paired multi tree %s: %v", pair.Multi, err) + continue + } + for rel, monoData := range monoFiles { + multiData, ok := multiFiles[rel] + if !ok { + t.Errorf("G4: paired multi tree %s missing %s from mono %s", pair.Multi, rel, pair.Mono) + continue + } if !bytes.Equal(monoData, multiData) { - t.Errorf("G4: paired files differ: mono %s vs multi %s", pair.Mono, pair.Multi) + t.Errorf("G4: paired tree file differs: mono %s/%s vs multi %s/%s", pair.Mono, rel, pair.Multi, rel) + } + } + for rel := range multiFiles { + if _, ok := monoFiles[rel]; !ok { + t.Errorf("G4: paired mono tree %s missing %s from multi %s", pair.Mono, rel, pair.Multi) } - default: - t.Errorf("G4: unknown paired mode %q", mode) } } @@ -318,6 +382,396 @@ func TestMonoMultiSkillContentG4Drift(t *testing.T) { } } +func TestMonoMultiSkillContentG5RelativeLinks(t *testing.T) { + root, c := loadMonoMultiQAContract(t) + multiRoot := filepath.Join(root, "skills", "multi") + sources := map[string]bool{} + + for _, pair := range c.PairedFiles { + if pair.Mono == "" || pair.Multi == "" { + t.Fatalf("G5: paired file requires mono and multi paths: %+v", pair) + } + sources[filepath.Join(root, "skills", "mono", filepath.FromSlash(pair.Mono))] = true + sources[filepath.Join(multiRoot, filepath.FromSlash(pair.Multi))] = true + } + for _, pair := range c.PairedTrees { + if pair.Mono == "" || pair.Multi == "" { + t.Fatalf("G5: paired tree requires mono and multi paths: %+v", pair) + } + trees := []string{ + filepath.Join(root, "skills", "mono", filepath.FromSlash(pair.Mono)), + filepath.Join(multiRoot, filepath.FromSlash(pair.Multi)), + } + for _, tree := range trees { + files, err := readRelativeFileTree(tree) + if err != nil { + t.Errorf("G5: read paired Markdown tree %s: %v", tree, err) + continue + } + for rel := range files { + sources[filepath.Join(tree, filepath.FromSlash(rel))] = true + } + } + } + + paths := make([]string, 0, len(sources)) + for path := range sources { + paths = append(paths, path) + } + sort.Strings(paths) + for _, source := range paths { + if !strings.EqualFold(filepath.Ext(source), ".md") { + continue + } + data, err := os.ReadFile(source) + if err != nil { + t.Errorf("G5: read %s: %v", source, err) + continue + } + rel, relErr := filepath.Rel(root, source) + if relErr != nil { + rel = source + } + for _, issue := range unresolvedRelativeMarkdownLinks(root, source, data) { + resolved := issue.Resolved + if resolved != "" { + if resolvedRel, err := filepath.Rel(root, resolved); err == nil { + resolved = filepath.ToSlash(resolvedRel) + } + } + t.Errorf("G5: %s:%d relative link %q resolves to %q: %v", filepath.ToSlash(rel), issue.Link.Line, issue.Link.Target, resolved, issue.Err) + } + } +} + +func TestMonoMultiSkillContentLinkNormalization(t *testing.T) { + rules := []monoMultiLinkSubstitution{ + {Mono: "../url-patterns.md", Multi: "../../dingtalk-shared/references/url-patterns.md"}, + {Mono: "../intent-guide.md", Multi: "sheet-intent-guide.md"}, + } + mono := []byte("url=../url-patterns.md\nintent=../intent-guide.md\n") + multi := []byte("url=../../dingtalk-shared/references/url-patterns.md\nintent=sheet-intent-guide.md\n") + if err := comparePairedFileContent(mono, multi, "link-normalized", rules); err != nil { + t.Fatalf("valid substitutions rejected: %v", err) + } + if err := comparePairedFileContent(mono, append(multi, []byte("drift\n")...), "link-normalized", rules); err == nil { + t.Fatal("unknown content drift must not be normalized away") + } + if err := comparePairedFileContent(mono, multi, "identical", rules); err == nil { + t.Fatal("identical mode must reject link substitutions") + } + if err := comparePairedFileContent(mono, multi, "unknown", nil); err == nil { + t.Fatal("unknown paired-file mode must fail closed") + } +} + +func TestMonoMultiSkillContentLinkNormalizationRejectsInvalidRules(t *testing.T) { + tests := []struct { + name string + mono string + multi string + rules []monoMultiLinkSubstitution + }{ + {name: "no rules", mono: "m1", multi: "x1"}, + {name: "empty mono", mono: "m1", multi: "x1", rules: []monoMultiLinkSubstitution{{Multi: "x1"}}}, + {name: "empty multi", mono: "m1", multi: "x1", rules: []monoMultiLinkSubstitution{{Mono: "m1"}}}, + {name: "same sides", mono: "m1", multi: "m1", rules: []monoMultiLinkSubstitution{{Mono: "m1", Multi: "m1"}}}, + {name: "duplicate mono", mono: "m1", multi: "x1 x2", rules: []monoMultiLinkSubstitution{{Mono: "m1", Multi: "x1"}, {Mono: "m1", Multi: "x2"}}}, + {name: "duplicate multi", mono: "m1 m2", multi: "x1", rules: []monoMultiLinkSubstitution{{Mono: "m1", Multi: "x1"}, {Mono: "m2", Multi: "x1"}}}, + {name: "overlapping mono", mono: "m1 m", multi: "x1 x2", rules: []monoMultiLinkSubstitution{{Mono: "m", Multi: "x1"}, {Mono: "m1", Multi: "x2"}}}, + {name: "missing occurrence", mono: "m1", multi: "x1", rules: []monoMultiLinkSubstitution{{Mono: "m2", Multi: "x1"}}}, + {name: "multiple occurrences", mono: "m1 m1", multi: "x1", rules: []monoMultiLinkSubstitution{{Mono: "m1", Multi: "x1"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := normalizePairedFileLinks([]byte(tt.mono), []byte(tt.multi), tt.rules); err == nil { + t.Fatal("invalid substitution rules must fail closed") + } + }) + } +} + +func TestMonoMultiSkillContentRelativeLinkScanner(t *testing.T) { + root := t.TempDir() + docs := filepath.Join(root, "docs") + if err := os.MkdirAll(filepath.Join(docs, "sheet"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(docs, "topic.md"), []byte("# Topic\n"), 0o644); err != nil { + t.Fatal(err) + } + body := []byte("[topic](./topic.md#section)\n[directory](./sheet/)\n[external](https://example.com/a)\n[email](mailto:test@example.com)\n[anchor](#section)\n`[inline](missing-inline.md)`\n```text\n[fenced](missing-fenced.md)\n```\n[missing](missing.md)\n[escape](../../escape.md)\n") + issues := unresolvedRelativeMarkdownLinks(root, filepath.Join(docs, "source.md"), body) + if len(issues) != 2 { + t.Fatalf("got %d unresolved links, want 2: %+v", len(issues), issues) + } + if issues[0].Link.Target != "missing.md" || issues[0].Link.Line != 10 { + t.Fatalf("unexpected unresolved link: %+v", issues[0]) + } + if issues[1].Link.Target != "../../escape.md" || issues[1].Link.Line != 11 || !strings.Contains(issues[1].Err.Error(), "escapes repository") { + t.Fatalf("unexpected escaping link: %+v", issues[1]) + } +} + +func TestMonoMultiSkillContentReadRelativeFileTreeFiltersNonMarkdown(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{"nested", ".hidden"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + "guide.md": "visible", + "nested/topic.md": "nested", + "notes.txt": "ignored", + ".DS_Store": "ignored", + ".draft.md": "ignored", + ".hidden/topic.md": "ignored", + "nested/README.MD": "accepted", + "nested/.hidden.md": "ignored", + } + for rel, body := range files { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := readRelativeFileTree(root) + if err != nil { + t.Fatal(err) + } + want := []string{"guide.md", "nested/README.MD", "nested/topic.md"} + gotNames := make([]string, 0, len(got)) + for rel := range got { + gotNames = append(gotNames, rel) + } + sort.Strings(gotNames) + if strings.Join(gotNames, "\n") != strings.Join(want, "\n") { + t.Fatalf("visible Markdown tree = %v, want %v", gotNames, want) + } +} + +func comparePairedFileContent(monoData, multiData []byte, mode string, substitutions []monoMultiLinkSubstitution) error { + switch mode { + case "identical": + if len(substitutions) != 0 { + return fmt.Errorf("identical mode does not allow link_substitutions") + } + if !bytes.Equal(monoData, multiData) { + return fmt.Errorf("contents differ") + } + return nil + case "link-normalized": + monoNormalized, multiNormalized, err := normalizePairedFileLinks(monoData, multiData, substitutions) + if err != nil { + return err + } + if !bytes.Equal(monoNormalized, multiNormalized) { + return fmt.Errorf("contents differ after declared link substitutions") + } + return nil + default: + return fmt.Errorf("unknown paired mode %q", mode) + } +} + +func normalizePairedFileLinks(monoData, multiData []byte, substitutions []monoMultiLinkSubstitution) ([]byte, []byte, error) { + if len(substitutions) == 0 { + return nil, nil, fmt.Errorf("link-normalized mode requires link_substitutions") + } + for i, substitution := range substitutions { + if substitution.Mono == "" || substitution.Multi == "" { + return nil, nil, fmt.Errorf("link_substitutions[%d] requires non-empty mono and multi values", i) + } + if substitution.Mono == substitution.Multi { + return nil, nil, fmt.Errorf("link_substitutions[%d] must describe a layout difference", i) + } + for j := 0; j < i; j++ { + previous := substitutions[j] + if strings.Contains(substitution.Mono, previous.Mono) || strings.Contains(previous.Mono, substitution.Mono) { + return nil, nil, fmt.Errorf("link_substitutions[%d].mono overlaps link_substitutions[%d].mono", i, j) + } + if strings.Contains(substitution.Multi, previous.Multi) || strings.Contains(previous.Multi, substitution.Multi) { + return nil, nil, fmt.Errorf("link_substitutions[%d].multi overlaps link_substitutions[%d].multi", i, j) + } + } + if count := bytes.Count(monoData, []byte(substitution.Mono)); count != 1 { + return nil, nil, fmt.Errorf("link_substitutions[%d].mono occurs %d times, want exactly 1", i, count) + } + if count := bytes.Count(multiData, []byte(substitution.Multi)); count != 1 { + return nil, nil, fmt.Errorf("link_substitutions[%d].multi occurs %d times, want exactly 1", i, count) + } + } + + monoNormalized := bytes.Clone(monoData) + multiNormalized := bytes.Clone(multiData) + for i, substitution := range substitutions { + marker := []byte(fmt.Sprintf("\x00DWS_LINK_SUBSTITUTION_%d\x00", i)) + if bytes.Contains(monoNormalized, marker) || bytes.Contains(multiNormalized, marker) { + return nil, nil, fmt.Errorf("link_substitutions[%d] normalization marker already exists in content", i) + } + monoNormalized = bytes.Replace(monoNormalized, []byte(substitution.Mono), marker, 1) + multiNormalized = bytes.Replace(multiNormalized, []byte(substitution.Multi), marker, 1) + } + return monoNormalized, multiNormalized, nil +} + +func unresolvedRelativeMarkdownLinks(repoRoot, source string, body []byte) []markdownLinkIssue { + var issues []markdownLinkIssue + for _, link := range extractMarkdownLinks(body) { + targetPath, check, err := relativeMarkdownTargetPath(link.Target) + if err != nil { + issues = append(issues, markdownLinkIssue{Link: link, Err: err}) + continue + } + if !check { + continue + } + resolved := filepath.Clean(filepath.Join(filepath.Dir(source), filepath.FromSlash(targetPath))) + rel, err := filepath.Rel(repoRoot, resolved) + if err != nil { + issues = append(issues, markdownLinkIssue{Link: link, Resolved: resolved, Err: err}) + continue + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + issues = append(issues, markdownLinkIssue{Link: link, Resolved: resolved, Err: fmt.Errorf("target escapes repository")}) + continue + } + if _, err := os.Stat(resolved); err != nil { + issues = append(issues, markdownLinkIssue{Link: link, Resolved: resolved, Err: err}) + continue + } + realRoot, err := filepath.EvalSymlinks(repoRoot) + if err != nil { + issues = append(issues, markdownLinkIssue{Link: link, Resolved: resolved, Err: fmt.Errorf("resolve repository path: %w", err)}) + continue + } + realTarget, err := filepath.EvalSymlinks(resolved) + if err != nil { + issues = append(issues, markdownLinkIssue{Link: link, Resolved: resolved, Err: fmt.Errorf("resolve target path: %w", err)}) + continue + } + realRel, err := filepath.Rel(realRoot, realTarget) + if err != nil || realRel == ".." || strings.HasPrefix(realRel, ".."+string(os.PathSeparator)) { + if err == nil { + err = fmt.Errorf("target escapes repository through a symbolic link") + } + issues = append(issues, markdownLinkIssue{Link: link, Resolved: resolved, Err: err}) + } + } + return issues +} + +func extractMarkdownLinks(body []byte) []markdownLink { + var links []markdownLink + var fence byte + for i, line := range strings.Split(string(body), "\n") { + if marker := markdownFenceMarker(line); marker != 0 { + if fence == 0 { + fence = marker + } else if fence == marker { + fence = 0 + } + continue + } + if fence != 0 { + continue + } + masked := maskInlineMarkdownCode(line) + for _, match := range inlineMarkdownLinkPattern.FindAllStringSubmatch(masked, -1) { + target := match[1] + if strings.HasPrefix(target, "<") && strings.HasSuffix(target, ">") { + target = strings.TrimSuffix(strings.TrimPrefix(target, "<"), ">") + } + links = append(links, markdownLink{Line: i + 1, Target: target}) + } + } + return links +} + +func markdownFenceMarker(line string) byte { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "```") { + return '`' + } + if strings.HasPrefix(trimmed, "~~~") { + return '~' + } + return 0 +} + +func maskInlineMarkdownCode(line string) string { + masked := []byte(line) + for i := 0; i < len(masked); { + if masked[i] != '`' { + i++ + continue + } + endOfOpener := i + for endOfOpener < len(masked) && masked[endOfOpener] == '`' { + endOfOpener++ + } + marker := masked[i:endOfOpener] + closingOffset := bytes.Index(masked[endOfOpener:], marker) + if closingOffset < 0 { + i = endOfOpener + continue + } + end := endOfOpener + closingOffset + len(marker) + for j := i; j < end; j++ { + masked[j] = ' ' + } + i = end + } + return string(masked) +} + +func relativeMarkdownTargetPath(target string) (string, bool, error) { + parsed, err := url.Parse(target) + if err != nil { + return "", false, fmt.Errorf("parse target: %w", err) + } + if parsed.Scheme != "" || parsed.Host != "" || strings.HasPrefix(target, "//") || strings.HasPrefix(parsed.Path, "/") { + return "", false, nil + } + if parsed.Path == "" { + return "", false, nil + } + path, err := url.PathUnescape(parsed.Path) + if err != nil { + return "", false, fmt.Errorf("decode target path: %w", err) + } + return path, true, nil +} + +func readRelativeFileTree(root string) (map[string][]byte, error) { + files := map[string][]byte{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if path != root && strings.HasPrefix(entry.Name(), ".") { + return filepath.SkipDir + } + return nil + } + if strings.HasPrefix(entry.Name(), ".") || !strings.EqualFold(filepath.Ext(entry.Name()), ".md") { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = data + return nil + }) + return files, err +} + type skillFrontmatter struct { Name string `yaml:"name"` Description string `yaml:"description"`