From 2bd6b297b0140c60a5274e083bc8101302deffe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Thu, 6 Aug 2026 16:43:56 +0800 Subject: [PATCH 01/35] docs(chat): document post-send ID chain --- internal/helpers/chat.go | 27 ++++-- internal/helpers/chat_message_help_test.go | 92 +++++++++++++++++++ skills/mono/references/intent-guide.md | 4 +- skills/mono/references/products/chat.md | 26 +++++- skills/multi/dingtalk-chat/SKILL.md | 2 +- .../references/chat/chat-message.md | 16 ++++ 6 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 internal/helpers/chat_message_help_test.go diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index b4699df4..4dc8c7c9 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -1897,6 +1897,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 或渲染成内联图片。 @@ -3084,7 +3089,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 { @@ -3131,8 +3139,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 @@ -3189,12 +3200,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/skills/mono/references/intent-guide.md b/skills/mono/references/intent-guide.md index 69e34d65..e13e105d 100644 --- a/skills/mono/references/intent-guide.md +++ b/skills/mono/references/intent-guide.md @@ -45,6 +45,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 查未读会话列表 | @@ -311,6 +312,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` 的场景(推荐首选): - "按发送者搜索消息/指定多个群搜索/@我的消息多维度搜" — 支持关键词、发送者、@我、@指定人、多个会话等维度组合搜索 @@ -324,7 +326,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 bf9aaa37..6bddf3f9 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -652,6 +652,8 @@ dws chat message send --open-dingtalk-id --msg-type file --file #### 查询消息发送状态 — 查询以当前用户身份发送的消息的发送状态 查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。 + +发送成功时返回 openMessageId 和 openConversationId,可直接用于后续编辑或撤回。 ``` Usage: dws chat message query-send-status [flags] @@ -665,6 +667,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 ``` #### 撤回消息 — 撤回当前用户自己发出的消息 @@ -675,15 +694,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) ``` @@ -2253,7 +2270,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/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index d28df63b..c0d36cfd 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -91,7 +91,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/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 49949894..822ab951 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -159,6 +159,22 @@ dws chat message search --query "codereview" --group --star | `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`。 From 4110330575daec465cc1e8dd71df802169c2f294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Thu, 6 Aug 2026 18:00:32 +0800 Subject: [PATCH 02/35] fix(skills): keep chat route within context budget --- skills/multi/dingtalk-chat/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index c0d36cfd..aec72830 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -91,7 +91,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) | From ea7c66b190558ba7a30bba60fb869fba88bb4318 Mon Sep 17 00:00:00 2001 From: xiatian Date: Fri, 7 Aug 2026 17:43:21 +0800 Subject: [PATCH 03/35] docs(sheet): align mono and multi skill references Replace the oversized mono Sheet reference with the progressive routing layout, mirror all Sheet topic references across both bundles, and add a paired-tree drift guard. --- CHANGELOG.md | 4 + skills/content-qa/mono-multi-coverage.yaml | 11 +- skills/mono/references/products/sheet.md | 2339 ++--------------- .../products/sheet/sheet-batch-operations.md | 89 +- .../references/products/sheet/sheet-chart.md | 58 +- .../products/sheet/sheet-comment.md | 166 ++ .../sheet/sheet-conditional-format.md | 13 +- .../sheet/sheet-dimension-operations.md | 55 +- .../products/sheet/sheet-dropdown.md | 8 +- .../references/products/sheet/sheet-export.md | 13 +- .../products/sheet/sheet-filter-view.md | 5 +- .../references/products/sheet/sheet-filter.md | 6 +- .../products/sheet/sheet-formula.md | 192 ++ .../references/products/sheet/sheet-import.md | 4 +- .../products/sheet/sheet-media-image.md | 17 +- .../products/sheet/sheet-pivot-table.md | 311 ++- .../products/sheet/sheet-range-operations.md | 12 +- .../products/sheet/sheet-read-data.md | 79 +- .../products/sheet/sheet-style-format.md | 6 +- .../products/sheet/sheet-version.md | 69 + .../products/sheet/sheet-workbook.md | 70 +- .../products/sheet/sheet-write-data.md | 208 +- .../multi/dingtalk-misc/references/sheet.md | 24 +- .../sheet/sheet-batch-operations.md | 10 +- .../references/sheet/sheet-comment.md | 4 +- .../sheet/sheet-dimension-operations.md | 8 +- .../references/sheet/sheet-export.md | 14 +- .../references/sheet/sheet-formula.md | 5 +- .../references/sheet/sheet-import.md | 163 +- .../references/sheet/sheet-pivot-table.md | 2 +- .../references/sheet/sheet-workbook.md | 3 +- test/unit/mono_multi_skill_content_test.go | 83 + 32 files changed, 1542 insertions(+), 2509 deletions(-) create mode 100644 skills/mono/references/products/sheet/sheet-comment.md create mode 100644 skills/mono/references/products/sheet/sheet-formula.md create mode 100644 skills/mono/references/products/sheet/sheet-version.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 277cf601..ac79f464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Changed +- **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 CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged. ### Fixed diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index c7a6da11..b33a6692 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -142,12 +142,21 @@ 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 use an explicit normalization mode. paired_files: + - mono: references/products/sheet.md + multi: dingtalk-misc/references/sheet.md + mode: shared-link-normalized - 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/products/sheet.md b/skills/mono/references/products/sheet.md index 40ccbe53..26fcc52c 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -1,2160 +1,263 @@ # 电子表格 (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` +## 局部意图与 Recipe -```bash -# ── 工作流 8: 合并单元格 ── - -# 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` +- [局部意图消歧](sheet-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..e713b3ed 100644 --- a/skills/mono/references/products/sheet/sheet-dimension-operations.md +++ b/skills/mono/references/products/sheet/sheet-dimension-operations.md @@ -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`。 @@ -183,32 +184,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 +302,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 +333,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` 输出使用 `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..b95abc32 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -47,13 +47,13 @@ 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` 产品对应的导出工具。 @@ -82,7 +82,8 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` | `downloadUrl`(未指定 --output)/ `导出完成: `(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | ## 注意事项 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-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md index bfa4bc1f..da8e6aaa 100644 --- a/skills/mono/references/products/sheet/sheet-style-format.md +++ b/skills/mono/references/products/sheet/sheet-style-format.md @@ -26,7 +26,7 @@ 用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号": - 仅设样式不改值 → `range set-style` -- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(内部顺序循环调 `update_range`) +- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(按配置顺序执行多条样式更新) - 写值同时附带样式 → `range update --values` 中使用 `cellStyles` 字段(参见 sheet-write-data.md) - 请勿用 `range update --values` 写空/重写来模拟纯样式变更 @@ -121,7 +121,7 @@ Flags: ``` **特性说明**: -- CLI 侧顺序循环逐条调用 `update_range`(非服务端批量),运行时输出 `[N/M]` 进度 +- CLI 按配置顺序逐条执行样式更新(非原子事务),运行时输出 `[N/M]` 进度 - 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法 - 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误 @@ -219,7 +219,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 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..eae6ed42 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -26,13 +26,15 @@ - 复制并指定名称 → `copy --name "副本名称"` - 复制并指定位置 → `copy --index N` +用户说"显示网格线/显示网格/恢复网格线": +- 显示网格线 → `show-gridline`(连续多次调用幂等,不报错) + +用户说"隐藏网格线/去掉网格/关闭网格线/展示模式/看板模式": +- 隐藏网格线 → `hide-gridline`(连续多次调用幂等,不报错) + 用户说"删除工作表/移除工作表/删掉这个Sheet": - 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认) -用户说"显示网格线/隐藏网格线/去掉单元格网格": -- 显示网格线 → `show-gridline` -- 隐藏网格线 → `hide-gridline` - ## 命令详细参考 ### 创建钉钉表格文档 @@ -70,13 +72,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 +135,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 +190,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 +229,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 +258,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 +273,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 +297,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-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index bc0ee825..fcb10996 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 侧再实现轮询或重试** 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..e713b3ed 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-dimension-operations.md @@ -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` 完成 @@ -205,7 +205,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 +226,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`。 @@ -335,6 +335,6 @@ dws sheet update-dimension --node --sheet-id \ - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 - `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,不接受普通单元格矩形 - `group-dimension` 输出使用 `level`,且为 1-based;不要使用旧的 `depth` 字段 -- `sheet info` 是分组回读入口;`range read` / `csv-get` 不返回行列分组 +- `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..b95abc32 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -56,7 +56,7 @@ Flags: - 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 -**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 ## 核心工作流 @@ -78,24 +78,16 @@ 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` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | ## 注意事项 -- ★ `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`**;直接告知用户导出失败并建议稍后再试 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-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index fef1381c..eae6ed42 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -72,6 +72,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 +86,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..1f958f3f 100644 --- a/test/unit/mono_multi_skill_content_test.go +++ b/test/unit/mono_multi_skill_content_test.go @@ -45,6 +45,11 @@ type monoMultiQAContract struct { Multi string `yaml:"multi"` Mode string `yaml:"mode"` } `yaml:"paired_files"` + PairedTrees []struct { + Mono string `yaml:"mono"` + Multi string `yaml:"multi"` + Mode string `yaml:"mode"` + } `yaml:"paired_trees"` OrphanScriptsAllowlist []struct { Path string `yaml:"path"` Disposition string `yaml:"disposition"` @@ -260,11 +265,66 @@ func TestMonoMultiSkillContentG4Drift(t *testing.T) { if !bytes.Equal(monoData, multiData) { t.Errorf("G4: paired files differ: mono %s vs multi %s", pair.Mono, pair.Multi) } + case "shared-link-normalized": + monoLink := []byte("../url-patterns.md") + multiLink := []byte("../../dingtalk-shared/references/url-patterns.md") + if bytes.Count(monoData, monoLink) != 1 || bytes.Count(multiData, multiLink) != 1 { + t.Errorf("G4: normalized shared-link pair must contain exactly one layout-specific URL-pattern link: mono %s vs multi %s", pair.Mono, pair.Multi) + continue + } + marker := []byte("__DWS_SHARED_URL_PATTERNS__") + monoNormalized := bytes.Replace(monoData, monoLink, marker, 1) + multiNormalized := bytes.Replace(multiData, multiLink, marker, 1) + if !bytes.Equal(monoNormalized, multiNormalized) { + t.Errorf("G4: paired files differ after shared-link normalization: mono %s vs multi %s", pair.Mono, pair.Multi) + } default: t.Errorf("G4: unknown paired mode %q", mode) } } + 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 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) + } + } + } + allow := map[string]bool{} for _, row := range c.OrphanScriptsAllowlist { if row.Path == "" || row.Disposition == "" || row.Reason == "" { @@ -318,6 +378,29 @@ func TestMonoMultiSkillContentG4Drift(t *testing.T) { } } +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() { + 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"` From 0bb2b6ce98ea4db87350b5ba1c9b299842cad1fa Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Thu, 6 Aug 2026 16:23:25 +0800 Subject: [PATCH 04/35] feat(sheet): CSV export, style extensions and create-with-data Adds four capabilities and, for the style surface, moves to the interface that can actually express them. Added: - sheet create --values / --sheets / --styles: create a workbook and populate it in one command. --values takes a 2D array into the default sheet, --sheets takes typed tables across several sheets, --styles carries cell_styles / row_sizes / col_sizes / cell_merges. Every structure and enum is validated before the document is created, so an invalid config never leaves an orphan empty document behind. - sheet export --export-format csv: synchronous single-sheet RFC4180 export with --sheet-id, --range and --value-render-option. --output writes to a file (a directory gets sheet-export.csv), otherwise the CSV goes to stdout while the truncation warning goes to stderr, keeping stdout pipeable. - sheet update-dimension --size-type: pixel / standard (restore the default row height or column width) / auto (fit row height to content, ROWS only). - sheet replace --match-formula: search and replace inside formula text. - sheet range set-style --font-style / --font-line / --font-family / --border-styles-json. - sheet range batch-set-style --ranges: stamp one style across several sheet-qualified ranges. Changed (breaking Schema change, no CLI break): - sheet range set-style moves from update_range to set_cell_range. The update_range style channel exposes exactly eight properties (backgroundColors, fontSizes, horizontalAlignments, verticalAlignments, fontColors, fontWeights, wordWrap, numberFormat) and has no slot for italic, underline/line-through, font family or borders, so the four new dimensions are not expressible there. interface_ref becomes set_cell_range and the twelve style flags stop publishing a flat property, because the value now lands in cells[i][j].cellStyles.* with no single top-level field to name. - sheet range batch-set-style submits one atomic batch_update instead of looping update_range, so a partial failure no longer leaves half the ranges stamped. --continue-on-error becomes a server passthrough. Caps the fan-out at 100 ranges and 200000 cells in aggregate. - sheet export and sheet create declare interface_mode=composite: both route across several tools depending on the flags, so a single mcp ref was wrong. Schema hygiene: - Nineteen parameters that previously resolved through flag_name_inference into property names present in no request (bgColor, exportFormat, values, ...) are now reviewed mapping exclusions with a stated reason, so Schema omits the property instead of inventing one. schema-compatibility reports 17 changed fields: 13 on sheet.range_set_style (interface_ref plus twelve property mappings) and 2 each on sheet.submit_export_job and sheet.create_workspace_sheet (interface_mode plus interface_ref). No CLI flag is removed and no command path changes; the same invocation runs on both the old and the new binary. Landing this needs a decision on the Schema contract break. Tests: targeted sheet suites in internal/helpers and the interface disposition contract tests in internal/app pass; make build and gofmt clean; check-schema-catalog, check-generated-drift, check-command-surface, check-skill-commands and check-runtime-confirmation-truth all pass. --- CHANGELOG.md | 15 + ...ema_interface_disposition_contract_test.go | 12 +- .../cli/schema_parameter_mapping_ledger.go | 27 +- internal/helpers/coverage_pure_more_test.go | 7 +- internal/helpers/sheet.go | 10 +- internal/helpers/sheet_create_with_data.go | 708 +++++++++++++ .../helpers/sheet_create_with_data_test.go | 967 ++++++++++++++++++ internal/helpers/sheet_data.go | 5 + internal/helpers/sheet_dimension.go | 46 +- .../sheet_dimension_coverage_more_test.go | 129 +++ internal/helpers/sheet_export.go | 146 ++- internal/helpers/sheet_export_csv_test.go | 210 ++++ internal/helpers/sheet_style.go | 937 ++++++++++------- .../helpers/sheet_style_batch_budget_test.go | 258 +++++ .../helpers/sheet_style_coverage_more_test.go | 41 +- internal/helpers/sheet_style_test.go | 20 +- internal/helpers/sheet_workbook.go | 64 +- .../helpers/small_remaining_coverage_test.go | 2 +- .../sheet/sheet-dimension-operations.md | 76 +- .../references/products/sheet/sheet-export.md | 28 +- .../products/sheet/sheet-search-replace.md | 2 + .../products/sheet/sheet-style-format.md | 65 +- .../products/sheet/sheet-workbook.md | 47 + .../sheet/sheet-dimension-operations.md | 31 +- .../references/sheet/sheet-export.md | 28 +- .../references/sheet/sheet-search-replace.md | 2 + .../references/sheet/sheet-style-format.md | 63 +- .../references/sheet/sheet-workbook.md | 47 + 28 files changed, 3521 insertions(+), 472 deletions(-) create mode 100644 internal/helpers/sheet_create_with_data.go create mode 100644 internal/helpers/sheet_create_with_data_test.go create mode 100644 internal/helpers/sheet_export_csv_test.go create mode 100644 internal/helpers/sheet_style_batch_budget_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e316c3fd..4520108a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Added + +- **`sheet create` 支持建表即写入数据与样式** — 新增 `--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`),字段名对齐飞书 snake_case。所有结构与枚举在创建文档之前校验,非法配置不会留下白建的空文档。 +- **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名),不传则打印到 stdout。数据超出单次读取上限时截断并告警。 +- **`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`(四边边框)。 +- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。 + +### Changed + +- **`sheet range set-style` 后端切换为 `set_cell_range`(破坏性 Schema 变更)** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容,无 flag 删除。 +- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。 +- **`sheet export` / `sheet create` 声明为 composite(破坏性 Schema 变更)** — 两者都会按参数路由到多个工具(`--export-format csv` 走 `get_range_as_csv`;建表带数据串起建表→探活→写入→回读→样式),`mcp` 单一 ref 不准确。 + ### Changed - **Doc import upload fallback** — `dws doc import` no longer fails on file diff --git a/internal/app/schema_interface_disposition_contract_test.go b/internal/app/schema_interface_disposition_contract_test.go index c92a8d18..dbe70f58 100644 --- a/internal/app/schema_interface_disposition_contract_test.go +++ b/internal/app/schema_interface_disposition_contract_test.go @@ -34,7 +34,17 @@ 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_workspace_sheet", + mode: "composite", + reason: "With --values / --sheets / --styles the CLI chains sheet/create_workspace_sheet, get_all_sheets, update_sheet, set_range_from_csv or table_put, a get_range_as_csv read-back, and set_cell_range / update_dimension / merge_cells; no single direct MCP interface represents the orchestration.", + }, + { + canonical: "sheet.submit_export_job", + mode: "composite", + reason: "The CLI routes by --export-format: xlsx submits sheet/submit_export_job and polls sheet/query_export_job, while csv reads sheet/get_range_as_csv synchronously. No single direct MCP interface covers both branches.", }, { canonical: "sheet.range_read", diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index 9e4380b7..eaec19ff 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -555,6 +555,9 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.create_sheet_comment --node": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_sheet_comment --range": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_sheet_comment --sheet-id": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", + "sheet.create_workspace_sheet --sheets": "Composite orchestration input: the CLI feeds these typed tables to sheet/table_put after the workbook exists; create_workspace_sheet has no such property.", + "sheet.create_workspace_sheet --styles": "Composite orchestration input: the CLI expands this config into set_cell_range / update_dimension / merge_cells calls after the data write; create_workspace_sheet has no such property.", + "sheet.create_workspace_sheet --values": "Composite orchestration input: the CLI encodes this matrix as CSV for sheet/set_range_from_csv after the workbook exists; create_workspace_sheet has no such property.", "sheet.delete_cond_format --yes": "local confirmation control", "sheet.delete_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.delete_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", @@ -592,17 +595,37 @@ 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.", "sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.", "sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", + "sheet.submit_export_job --export-format": "Composite router input: --export-format selects between the xlsx submit_export_job branch and the synchronous csv get_range_as_csv branch; it is not a property of either RPC.", "sheet.submit_export_job --output": "local destination consumed after export job completion", + "sheet.submit_export_job --range": "Composite branch input: only the csv branch consumes it, as the range argument of sheet/get_range_as_csv; the declared xlsx submit_export_job interface has no such property.", + "sheet.submit_export_job --sheet-id": "Composite branch input: only the csv branch consumes it, as the sheetId argument of sheet/get_range_as_csv; the declared xlsx submit_export_job interface has no such property.", + "sheet.submit_export_job --value-render-option": "Composite branch input: only the csv branch consumes it, as the valueRenderOption argument of sheet/get_range_as_csv; the declared xlsx submit_export_job interface has no such property.", "sheet.table_get --no-header": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --no-header is a CLI wrapper input and does not publish a direct interface property.", "sheet.table_get --range": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.table_get --sheet-id": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", 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..4548514d 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -141,16 +141,16 @@ func newSheetCommand() *cobra.Command { CLIPath: "sheet export", PrimaryCLIPath: "sheet export", }, - Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载)。", + Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, Interface: &contract.InterfaceSpec{ - Mode: "mcp", + Mode: "composite", Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "submit_export_job"}, + Reason: "The CLI routes by --export-format: xlsx submits sheet/submit_export_job and polls sheet/query_export_job, while csv reads sheet/get_range_as_csv synchronously. No single direct MCP interface covers both branches.", }, Selection: contract.SelectionSpec{ - AgentSummary: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载)。", - UseWhen: []string{"需要把在线电子表格导出为 Excel 文件或拿到 downloadUrl 时"}, + AgentSummary: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", + UseWhen: []string{"需要把在线电子表格导出为 Excel 文件、CSV 或拿到 downloadUrl 时"}, AvoidWhen: []string{"禁止用 range read 拼 xlsx;本地已有 xlsx 节点用 doc download;Agent 不要外层再轮询"}, Examples: []string{"dws sheet export --node --output ./report.xlsx"}, }, diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go new file mode 100644 index 00000000..a8d9ccb7 --- /dev/null +++ b/internal/helpers/sheet_create_with_data.go @@ -0,0 +1,708 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// ============================================================================ +// sheet create --values / --sheets / --styles 编排 +// +// 创建带初始数据(可选样式)的表格是多步流程,没有单一 MCP 工具承载: +// create_workspace_sheet → 探活 → 定位默认工作表 → 写数据 → 回读校验 → 应用样式 +// 所有结构/枚举校验都在发第一个请求之前完成,避免留下白建的空文档。 +// ============================================================================ + +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 { + if err := json.Unmarshal([]byte(valuesStr), &values); err != nil { + return fmt.Errorf("--values JSON 解析失败: %w", err) + } + // null / [] 都写不出任何数据,必须在建文档之前拒掉, + // 否则会白建一个空文档再由回读校验报成"写入未生效"。 + if len(values) == 0 { + return fmt.Errorf("--values 不能为空(需要形如 '[[\"姓名\",\"分数\"],[\"张三\",90]]' 的二维数组)") + } + if !valuesHaveContent(values) { + return fmt.Errorf("--values 全部单元格为空,写不出任何数据(需要至少一个非空单元格)") + } + } 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) + } + } + + 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 + if err := json.Unmarshal([]byte(sheetsStr), &payload); err != nil { + return nil, fmt.Errorf("--sheets JSON 解析失败: %w", err) + } + var arr []any + switch v := payload.(type) { + case []any: + arr = v + case map[string]any: + if s, ok := v["sheets"].([]any); ok { + 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)) + for i, item := range arr { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("--sheets[%d] 不是对象", i) + } + if name, _ := m["name"].(string); name == "" { + return nil, fmt.Errorf("--sheets[%d] 缺少必填的 name 字段(创建带数据时每个工作表必须命名)", i) + } + specs = append(specs, m) + } + return specs, nil +} + +// ── create --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 写法。 + +// 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)。 +func pickNum(m map[string]any, keys ...string) (int, bool) { + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + } + } + } + return 0, false +} + +// 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) { + 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 := pickNum(item, "font_size", "fontSize"); 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 +} + +// 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 { + var r1, r2 int + if _, e := fmt.Sscanf(a, "%d", &r1); e != nil || r1 < 1 { + return "", 0, fmt.Errorf("行范围非法: %s(应形如 \"1:3\")", addr) + } + if _, e := fmt.Sscanf(b, "%d", &r2); e != nil || 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 + } + c1, _, e1 := parseA1Cell(strings.ToUpper(a) + "1") + c2, _, e2 := parseA1Cell(strings.ToUpper(b) + "1") + if e1 != nil || e2 != nil { + return "", 0, fmt.Errorf("列范围非法: %s(应形如 \"A:C\")", addr) + } + // 反序范围(如 "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 数组") + } + 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 { + 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 := pickNum(item, "size") + if !ok || size <= 0 { + return fmt.Errorf("%s[%d] type=pixel 时必须提供正整数 size", label, i) + } + args["pixelSize"] = size + case typ == "standard": + // 尺寸由服务端读取默认行高/列宽决定,无需 size。 + // 给了 size 说明调用方预期不符,静默忽略会让人以为生效了。 + if _, ok := pickNum(item, "size"); ok { + return fmt.Errorf("%s[%d] type=standard 表示恢复默认尺寸,不能同时给 size;要指定固定像素请用 type=pixel", label, i) + } + case typ == "auto" && isRow: + // 行高按内容自适应,无需 size + if _, ok := pickNum(item, "size"); 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 { + rangeAddr := pickStr(item, "range") + if rangeAddr == "" { + return nil, fmt.Errorf("cell_merges[%d] 缺少必填的 range", i) + } + 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 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) +} + +// 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..72f86204 --- /dev/null +++ b/internal/helpers/sheet_create_with_data_test.go @@ -0,0 +1,967 @@ +package helpers + +import ( + "context" + "errors" + "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"}) +} + +// newCreateCmdForTest 取出 sheet create 命令本体,便于直接驱动 RunE。 +func newCreateCmdForTest(t *testing.T) *cobra.Command { + t.Helper() + for _, cmd := range newWorkbookCmds() { + if cmd.Name() == "create" { + return cmd + } + } + t.Fatal("create command not found in newWorkbookCmds()") + return nil +} + +// runCreate 用脚本化的 MCP 响应驱动一次 sheet create。 +func runCreate(t *testing.T, caller *scriptedToolCaller, flags map[string]string) error { + t.Helper() + installScriptedCaller(t, caller) + installImmediateTiming(t) + installSheetProductArgs(t) + cmd := newCreateCmdForTest(t) + 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 回读 + } +} + +func TestSheetCreateEmptyKeepsSingleCallBehaviour(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"nodeId":"N"}`}}} + if err := runCreate(t, caller, map[string]string{"name": "空表"}); err != nil { + t.Fatalf("create without data: %v", err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1 (不带初始数据时只调 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: "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 解析失败"}, + // `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-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"}, + {"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"},{"name":"B"}]`, "styles": `[{"name":"A","cell_merges":[{"range":"A1:B1"}]}]`}, + "必须与 --sheets 子表数", + }, + { + "styles-name-mismatch-with-sheets", + map[string]string{"name": "X", "sheets": `[{"name":"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", + }, + { + "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", + }, + { + "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"`, + }, + } + 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) + } + }) + } +} + +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 + }} + 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) + } + if caller.tool != "table_put" { + t.Fatalf("last tool = %q, want table_put", caller.tool) + } + // --sheets 路径不做 A1 回读校验,因此比 --values 少一次调用 + if caller.calls != 5 { + t.Fatalf("calls = %d, want 5", caller.calls) + } +} + +// 每个远端步骤失败时,错误必须带上已创建的 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 + }{ + {[]string{"n"}, 3, true}, + {[]string{"i"}, 4, true}, + {[]string{"missing"}, 0, false}, + {[]string{"nil", "bad"}, 0, false}, + } { + got, ok := 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) + } + } +} + +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}, // 列范围收到数字 + } + 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"},{"name":"B"}]`, + `{"sheets":[{"name":"A"}]}`, + `{"name":"A"}`, // 单个 spec 对象 + } { + 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: `{"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) + } + } +} 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..c3f495a4 100644 --- a/internal/helpers/sheet_dimension.go +++ b/internal/helpers/sheet_dimension.go @@ -9,6 +9,14 @@ import ( "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 { @@ -490,9 +498,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 @@ -526,8 +540,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 +583,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 } toolArgs["pixelSize"] = pixelSize } + if sizeType != "" { + toolArgs["sizeType"] = sizeType + } return callMCPTool("update_dimension", toolArgs) }, } @@ -587,6 +626,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..1cd0529a 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,57 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T) } } +// --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 +138,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.go b/internal/helpers/sheet_export.go index 98b09c33..b27a67f3 100644 --- a/internal/helpers/sheet_export.go +++ b/internal/helpers/sheet_export.go @@ -14,6 +14,16 @@ import ( ) func runSheetExport(cmd *cobra.Command, _ []string) error { + format, _ := cmd.Flags().GetString("export-format") + switch format { + case "", "xlsx": + // 继续走下方 xlsx 异步流程 + case "csv": + return runSheetExportCsv(cmd) + default: + return fmt.Errorf("--export-format 仅支持 xlsx 或 csv,当前值: %s", format) + } + nodeID := mustGetFlag(cmd, "node") if nodeID == "" { return fmt.Errorf("flag --node is required") @@ -246,8 +256,16 @@ func inferSheetExportFilename(rawURL string) string { func newExportCmd() *cobra.Command { exportCmd := &cobra.Command{ Use: "export", - Short: "导出表格为 xlsx(异步任务一站式)", - Long: `将钉钉在线电子表格导出为 Office xlsx 格式(单命令一站式)。 + Short: "导出表格为 xlsx(异步一站式)或 CSV(单表同步)", + Long: `将钉钉在线电子表格导出为 Office xlsx 或 CSV。 + +格式(--export-format): + xlsx(默认) 整篇表格导出为 xlsx,异步任务一站式(提交+轮询+下载,见下方流程) + csv 导出单个工作表为纯 RFC4180 CSV(同步)。用 --sheet-id 指定工作表、 + --range 限定范围、--value-render-option 选择取值模式;--output 落盘, + 不传则打印到 stdout。超大表会截断并给出警告,请用 --range 分块或改用 xlsx。 + +以下为 xlsx 异步流程: 执行流程(全程自动,无需 Agent 介入轮询): 1. 提交导出任务(submit_export_job),获取 jobId @@ -280,10 +298,132 @@ func newExportCmd() *cobra.Command { dws sheet export --node NODE_ID --output ./report.xlsx # --output 为目录时,自动按下载链接里的文件名保存 - dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --output ./`, + dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --output ./ + + # 导出单个工作表为 CSV 文件 + dws sheet export --node NODE_ID --export-format csv --sheet-id SHEET_ID --output ./data.csv + + # 导出 CSV 到 stdout(可管道处理) + dws sheet export --node NODE_ID --export-format csv --sheet-id SHEET_ID`, RunE: runSheetExport, } exportCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") exportCmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录)") + // 注意:不要把这个 flag 叫 --format。根级已有 persistent 的 -f/--format(输出格式), + // 同名局部 flag 会在 cobra 的 flag 合并中把它整个挤掉,导致 -f 变成未知简写、 + // 且 --format 被这里吞掉(命名与 aitable export-data 的 --export-format 一致)。 + exportCmd.Flags().String("export-format", "xlsx", "导出格式: xlsx(默认,异步任务) / csv(单个工作表,同步)") + exportCmd.Flags().String("sheet-id", "", "工作表 ID 或名称(--export-format csv 时指定要导出的工作表,不传则第一个)") + exportCmd.Flags().String("range", "", "导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出)") + exportCmd.Flags().String("value-render-option", "", "取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula") return exportCmd } + +// valueRenderOptionEnum 是 --value-render-option 的合法取值(仅 csv 路径生效)。 +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) 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") + + 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 + } + + if hasMore { + deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断。" + + "请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 --export-format 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") + } + if err := os.WriteFile(outputPath, []byte(csvContent), 0o644); err != nil { + return fmt.Errorf("写入 CSV 文件失败: %w", err) + } + 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..1702f790 --- /dev/null +++ b/internal/helpers/sheet_export_csv_test.go @@ -0,0 +1,210 @@ +// 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" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSheetExportRejectsUnknownExportFormat(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeSheetExportCoverage(t, caller, "node", "NODE", "export-format", "pdf") + if err == nil || !strings.Contains(err.Error(), "--export-format 仅支持 xlsx 或 csv") { + t.Fatalf("err = %v, want unsupported export format", err) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, want 0", caller.calls) + } +} + +func TestSheetExportCsvRequiresNode(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeSheetExportCoverage(t, caller, "export-format", "csv") + 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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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 := executeSheetExportCoverage(t, ok, + "node", "NODE", "export-format", "csv", "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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", + "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 TestSheetExportCsvForwardsRangeAndWarnsOnTruncation(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"csv":"a,b\n","hasMore":true}`}, + }} + if err := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "range", "A1:Z1000"); err != nil { + t.Fatalf("csv export: %v", err) + } + if got := caller.args["range"]; got != "A1:Z1000" { + t.Fatalf("range 未透传: %#v", caller.args["range"]) + } + // csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。 + if caller.args["annotateRowNumbers"] != false { + t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"]) + } +} + +func TestSheetExportCsvWritesIntoDirectory(t *testing.T) { + dir := t.TempDir() + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} + if err := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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)) + } +} + +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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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 := executeSheetExportCoverage(t, caller, "node", "NODE", "export-format", "csv") + 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 := executeSheetExportCoverage(t, caller, "node", "NODE", "export-format", "csv", "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 := newExportCmd() + for _, kv := range [][2]string{{"node", "NODE"}, {"export-format", "csv"}} { + if err := cmd.Flags().Set(kv[0], kv[1]); err != nil { + t.Fatalf("set --%s: %v", kv[0], err) + } + } + if err := runSheetExport(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..5fa5b1d2 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,290 @@ 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} + +// 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, _ := spec["style"].(string) + 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 color, ok := spec["color"].(string); ok && color != "" { + 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 +401,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 内)`) +} + +// 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 +510,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 +540,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 +584,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 +598,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 +672,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 +691,122 @@ 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)。 +func splitSheetPrefixedRange(rng string, idx int) (string, string, error) { + i := strings.Index(rng, "!") + if i <= 0 || i == len(rng)-1 { + return "", "", fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", idx, rng) + } + return strings.TrimSpace(rng[:i]), strings.TrimSpace(rng[i+1:]), 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 { + if item.SheetID == "" || 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..8d23c0a7 --- /dev/null +++ b/internal/helpers/sheet_style_batch_budget_test.go @@ -0,0 +1,258 @@ +// 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: "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) + } + }) + } +} 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..c70a1d4b 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) } diff --git a/internal/helpers/sheet_workbook.go b/internal/helpers/sheet_workbook.go index f8d8a422..258438bc 100644 --- a/internal/helpers/sheet_workbook.go +++ b/internal/helpers/sheet_workbook.go @@ -10,13 +10,46 @@ import ( func newWorkbookCmds() []*cobra.Command { createCmd := &cobra.Command{ Use: "create", - Short: "创建钉钉表格文档", - Long: `创建一篇新的钉钉在线电子表格,支持创建空表格。 + Short: "创建钉钉表格文档(可带初始数据)", + Long: `创建一篇新的钉钉在线电子表格,支持创建空表格或创建时写入初始数据。 -创建位置优先级: --folder > --workspace > 默认 (我的文档根目录)`, +创建位置优先级: --folder > --workspace > 默认 (我的文档根目录) + +初始数据(--values 与 --sheets 二选一,均为可选): + --values 二维 JSON 数组,裸值写入默认工作表 A1 起(单表快速建表,无表头/类型语义, + 复用 csv-put 通道,自动识别数字/布尔) + --sheets typed table 数组,多工作表一次写入(复用 table-put 通道)。每个条目形如 + {"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]} + name 必填;第一个条目写入默认工作表(自动重命名为其 name),其余按 name 自动新建。 + 支持列类型(dtypes)、数字格式(formats)、单元格样式(cellStyles)。 + +样式配置(--styles,可选,需与 --values 或 --sheets 搭配;字段名对齐飞书 snake_case): + {"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 --name "销售数据" dws sheet create --name "Q1 数据" --folder FOLDER_ID - dws sheet create --name "知识库表格" --workspace WS_ID`, + dws sheet create --name "知识库表格" --workspace WS_ID + + # 创建并写入初始数据(默认工作表,裸二维值) + dws sheet create --name "名单" --values '[["姓名","分数"],["张三","90"]]' + + # 创建多个带数据的工作表(typed table) + dws sheet create --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' + + # 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽、合并) + dws sheet create --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}]}]}'`, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{ "name": mustGetFlag(cmd, "name"), @@ -27,7 +60,21 @@ func newWorkbookCmds() []*cobra.Command { if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v } - return callMCPTool("create_workspace_sheet", toolArgs) + + valuesStr, _ := cmd.Flags().GetString("values") + sheetsStr, _ := cmd.Flags().GetString("sheets") + stylesStr, _ := cmd.Flags().GetString("styles") + if valuesStr == "" && sheetsStr == "" { + if stylesStr != "" { + return fmt.Errorf("--styles 需与 --values 或 --sheets 搭配使用") + } + // 无初始数据:保持原行为(创建空表并直接输出结果) + return callMCPTool("create_workspace_sheet", toolArgs) + } + if valuesStr != "" && sheetsStr != "" { + return fmt.Errorf("--values 与 --sheets 二选一,不能同时指定") + } + return runCreateSheetWithData(cmd, toolArgs, valuesStr, sheetsStr, stylesStr) }, } DeclareLeafMetadata(createCmd, LeafSpec{ @@ -45,9 +92,9 @@ func newWorkbookCmds() []*cobra.Command { }, Description: "创建钉钉在线电子表格文档(axls),返回 nodeId。", Interface: &contract.InterfaceSpec{ - Mode: "mcp", + Mode: "composite", Availability: "available", - Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_workspace_sheet"}, + Reason: "With --values / --sheets / --styles the CLI chains sheet/create_workspace_sheet, get_all_sheets, update_sheet, set_range_from_csv or table_put, a get_range_as_csv read-back, and set_cell_range / update_dimension / merge_cells; no single direct MCP interface represents the orchestration.", }, Selection: contract.SelectionSpec{ AgentSummary: "创建钉钉在线电子表格文档(axls),返回 nodeId。", @@ -69,6 +116,9 @@ func newWorkbookCmds() []*cobra.Command { createCmd.Flags().String("name", "", "表格名称 (必填)") createCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL") createCmd.Flags().String("workspace", "", "目标知识库 ID") + createCmd.Flags().String("values", "", "初始数据,二维 JSON 数组,写入默认工作表 (与 --sheets 二选一)") + createCmd.Flags().String("sheets", "", `多工作表 typed table JSON,如 '[{"name":"表名","columns":[...],"data":[[...]]}]' (与 --values 二选一)`) + createCmd.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"}]}]}`) listCmd := &cobra.Command{ Use: "list", 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/skills/mono/references/products/sheet/sheet-dimension-operations.md b/skills/mono/references/products/sheet/sheet-dimension-operations.md index afc2f5ac..21f33859 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` 是分组回读入口;`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-export.md b/skills/mono/references/products/sheet/sheet-export.md index 891da573..fc4454c0 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 --export-format csv`(**同步**,不走异步任务) +- 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式 +- 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理 + ## 命令详细参考 ### 导出表格为 xlsx(异步任务一站式) @@ -29,8 +34,12 @@ Example: dws sheet export --node --output ./ Flags: - --node string 表格文档 ID 或 URL (必填) - --output string 本地保存路径(可选,支持文件路径或目录) + --node string 表格文档 ID 或 URL (必填) + --output string 本地保存路径(可选,支持文件路径或目录) + --export-format string 导出格式: xlsx(默认,异步任务) / csv(单个工作表,同步) (default "xlsx") + --sheet-id string 工作表 ID 或名称(仅 --export-format csv,不传则第一个) + --range string 导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出) + --value-render-option string 取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula ``` 将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 @@ -40,6 +49,21 @@ Flags: 2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 +**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` 生效。不传 `--output` 时 CSV 正文打印到 stdout。超大表会截断并在 stderr 给出警告,此时用 `--range` 分块导出或改用默认的 xlsx。 + +```bash +# 落盘到本地 +dws sheet export --node --export-format csv --sheet-id --output ./data.csv + +# 输出到 stdout 便于管道处理 +dws sheet export --node --export-format csv --sheet-id + +# 大表分块导出(避免截断) +dws sheet export --node --export-format csv --sheet-id --range "A1:Z1000" --output ./part1.csv +``` + +注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用默认的 xlsx。 + **内置轮询策略(CLI 内实现,无需关心)**: - 第 1~5 次:每次间隔 2 秒 - 第 6~10 次:每次间隔 5 秒 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..dcfc1a3d 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 字段);只传的边生效、未传的边保留原状(按边合并)。**逐格应用**:对整个 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,10 @@ 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 + 枚举合法),**任一项校验不过即整批不下发** +- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) +- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 ### 合并单元格 ``` @@ -219,7 +256,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 +301,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-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index 1d1006b1..59c4df69 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -47,6 +47,53 @@ 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 搭配) +``` + +**创建时写入初始数据**(`--values` 与 `--sheets` 二选一,都不传则创建空表): + +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 + `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; + `name` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 + +**建表时一并应用样式**(`--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`** +- `merge_type` 取 `all` / `rows` / `columns` + +**行为要点**: +- 所有 JSON 结构与枚举都在**创建文档之前**校验,非法配置直接失败,不会留下白建的空文档 +- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- `--values` 写完会回读 A1 校验确实落盘;若未落盘会报错并提示用 `csv-put` / `range update` 补写 +- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 + +示例: + +```bash +# 创建并写入初始数据(默认工作表,裸二维值) +dws sheet create --name "名单" --values '[["姓名","分数"],["张三","90"]]' + +# 创建多个带数据的工作表 +dws sheet create --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' + +# 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽) +dws sheet create --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}]}]}' ``` > **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 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..21f33859 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` 是分组回读入口;`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..c9dc23c9 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 --export-format csv`(**同步**,不走异步任务) +- 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式 +- 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理 + ## 命令详细参考 ### 导出表格为 xlsx(异步任务一站式) @@ -29,8 +34,12 @@ Example: dws sheet export --node --output ./ Flags: - --node string 表格文档 ID 或 URL (必填) - --output string 本地保存路径(可选,支持文件路径或目录) + --node string 表格文档 ID 或 URL (必填) + --output string 本地保存路径(可选,支持文件路径或目录) + --export-format string 导出格式: xlsx(默认,异步任务) / csv(单个工作表,同步) (default "xlsx") + --sheet-id string 工作表 ID 或名称(仅 --export-format csv,不传则第一个) + --range string 导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出) + --value-render-option string 取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula ``` 将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 @@ -40,6 +49,21 @@ Flags: 2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 +**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` 生效。不传 `--output` 时 CSV 正文打印到 stdout。超大表会截断并在 stderr 给出警告,此时用 `--range` 分块导出或改用默认的 xlsx。 + +```bash +# 落盘到本地 +dws sheet export --node --export-format csv --sheet-id --output ./data.csv + +# 输出到 stdout 便于管道处理 +dws sheet export --node --export-format csv --sheet-id + +# 大表分块导出(避免截断) +dws sheet export --node --export-format csv --sheet-id --range "A1:Z1000" --output ./part1.csv +``` + +注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用默认的 xlsx。 + **内置轮询策略(CLI 内实现,无需关心)**: - 第 1~5 次:每次间隔 2 秒 - 第 6~10 次:每次间隔 5 秒 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..dcfc1a3d 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 字段);只传的边生效、未传的边保留原状(按边合并)。**逐格应用**:对整个 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,10 @@ 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 + 枚举合法),**任一项校验不过即整批不下发** +- `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) +- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 ### 合并单元格 ``` @@ -264,7 +301,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..16e9ce96 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -49,6 +49,53 @@ 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 搭配) +``` + +**创建时写入初始数据**(`--values` 与 `--sheets` 二选一,都不传则创建空表): + +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 + `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; + `name` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 + +**建表时一并应用样式**(`--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`** +- `merge_type` 取 `all` / `rows` / `columns` + +**行为要点**: +- 所有 JSON 结构与枚举都在**创建文档之前**校验,非法配置直接失败,不会留下白建的空文档 +- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- `--values` 写完会回读 A1 校验确实落盘;若未落盘会报错并提示用 `csv-put` / `range update` 补写 +- 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 + +示例: + +```bash +# 创建并写入初始数据(默认工作表,裸二维值) +dws sheet create --name "名单" --values '[["姓名","分数"],["张三","90"]]' + +# 创建多个带数据的工作表 +dws sheet create --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' + +# 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽) +dws sheet create --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}]}]}' ``` > **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 From c1a90c0194f6b274a6048a15d0caadc212620f19 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Thu, 6 Aug 2026 17:43:55 +0800 Subject: [PATCH 05/35] fix(sheet): reject fractional and out-of-range --styles sizes instead of truncating P2 from automated review. pickNum ran int(n) straight on the float64 that JSON decoding always produces, so font_size: 12.9 and row_sizes.size: 28.5 were silently rewritten to 12 and 28 and then executed as a valid configuration. Both the help text and the error messages state these fields must be positive integers, and --styles is a non-atomic sequence that cannot be rolled back, so truncation left a sheet that did not match what the caller asked for. Measured before the fix: font_size=12.9 -> emitted fontSize=12 size=28.5 -> emitted pixelSize=28 size=1e20 -> emitted pixelSize=9223372036854775807 The overflow case was worse than reported: int(1e20) saturates to MaxInt64 and was still sent. pickNum now returns an error and rejects a non-integral value, a NaN or Inf, a magnitude outside int32, and a non-numeric type. A missing key and an explicit null still report "not provided" without an error, so optional fields keep working. Every call site propagates the error, which means the whole --styles payload is refused before the document is created. Confirmed through the real CLI: font_size=12.9, col_sizes size=120.5 and size=1e20 all fail with a specific message while font_size=12 still passes. Tests: - TestPickNumRejectsNonIntegralAndOutOfRange covers eight rejected inputs plus five accepted ones, the missing key, an explicit null, and alias-key lookup. - Four new cases in TestSheetCreateValidatesBeforeCreatingDocument for cell_styles font_size, row_sizes size, col_sizes size and the overflow, each asserting calls == 0. - TestPickStrAndPickNum updated: a bool value now surfaces a type error with ok=true rather than being reported as absent. --- internal/helpers/sheet_create_with_data.go | 58 +++++++-- .../helpers/sheet_create_with_data_test.go | 111 ++++++++++++++++-- 2 files changed, 148 insertions(+), 21 deletions(-) diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index a8d9ccb7..3e61b0e3 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -6,6 +6,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "math" "regexp" "strconv" "strings" @@ -233,19 +234,37 @@ func pickStr(m map[string]any, keys ...string) string { return "" } -// pickNum 按多个候选键取数值(JSON 数字为 float64)。 -func pickNum(m map[string]any, keys ...string) (int, bool) { +// 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 { - if v, ok := m[k]; ok && v != nil { - switch n := v.(type) { - case float64: - return int(n), true - case int: - return n, true + 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 + return 0, false, nil } // feishuWordWrap 把飞书 word_wrap 枚举映射为引擎枚举(同时接受引擎原生值)。 @@ -304,7 +323,9 @@ func cellStyleItemToSpec(item map[string]any) (*styleSpec, string, error) { WordWrap: feishuWordWrap(pickStr(item, "word_wrap", "wordWrap")), NumberFormat: pickStr(item, "number_format", "numberFormat"), } - if n, ok := pickNum(item, "font_size", "fontSize"); ok { + 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 校验。 @@ -467,7 +488,10 @@ func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error } switch { case typ == "pixel": - size, ok := pickNum(item, "size") + 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) } @@ -475,12 +499,20 @@ func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error case typ == "standard": // 尺寸由服务端读取默认行高/列宽决定,无需 size。 // 给了 size 说明调用方预期不符,静默忽略会让人以为生效了。 - if _, ok := pickNum(item, "size"); ok { + _, 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 - if _, ok := pickNum(item, "size"); ok { + _, 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: diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 72f86204..756ad071 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -3,6 +3,7 @@ package helpers import ( "context" "errors" + "math" "os" "strings" "testing" @@ -168,6 +169,29 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { 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}]}]}`}, @@ -565,19 +589,29 @@ func TestPickStrAndPickNum(t *testing.T) { t.Fatalf("pickStr = %q, want empty", got) } for _, tc := range []struct { - keys []string - want int - ok bool + keys []string + want int + ok bool + wantErr string }{ - {[]string{"n"}, 3, true}, - {[]string{"i"}, 4, true}, - {[]string{"missing"}, 0, false}, - {[]string{"nil", "bad"}, 0, false}, + {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 := pickNum(m, tc.keys...) + 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) + } } } @@ -965,3 +999,64 @@ func TestValuesHaveContent(t *testing.T) { } } } + +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) + } +} From b8ac9810f404a8f9da4fe72de5db738d96f8b3a4 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 09:59:11 +0800 Subject: [PATCH 06/35] fix(sheet): require unique --sheets names and cover the size error branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both in the create-with-data path. Unique worksheet names. parseCreateSheetSpecs accepted a --sheets payload with repeated names, so table_put created several worksheets sharing one name. The style tools locate a worksheet by "id or name", so --styles would then land on whichever duplicate the server picked, and --styles runs after the document already exists and cannot be rolled back. Duplicates are now refused before anything is created, naming the first occurrence: --sheets[1].name="一月" 与 --sheets[0] 重复;工作表名必须唯一,... Case-only differences are still accepted: the server distinguishes them and the CLI should not tighten that. --styles needs no equivalent check because it already requires name equality with the corresponding --sheets entry, and those are now unique. Coverage. The previous commit added a precise pickNum error path to the standard and auto branches of planSizes, but no test reached it: the existing cases used an integer size, which stops at "不能同时给 size" before the numeric check runs. The CI coverage gate therefore reported 99.7619% on changed code (sheet_create_with_data.go:512-514 and :521-523). Two cases now drive type=standard and type=auto with size 28.5. That pair is not only about the percentage. It pins the error precedence: a fractional size must report "size=28.5 必须是整数", which points at the field actually written wrong, rather than the generic "不能同时给 size". Swapping the two checks would make the message misleading and now fails the tests. Changed-code coverage measured locally at 100.0000% (845/845 statements), with no uncovered blocks in the diff against upstream/main. Tests: - TestParseCreateSheetSpecsRejectsDuplicateNames covers adjacent, non-adjacent and {"sheets":[...]}-wrapped duplicates, plus three payloads that must pass. - Three new cases in TestSheetCreateValidatesBeforeCreatingDocument (sheets-duplicate-name and the two fractional sizes), each asserting calls == 0. --- internal/helpers/sheet_create_with_data.go | 11 +++- .../helpers/sheet_create_with_data_test.go | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 3e61b0e3..5176d4ac 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -185,14 +185,23 @@ func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { 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 name, _ := m["name"].(string); name == "" { + name, _ := m["name"].(string) + if 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 specs = append(specs, m) } return specs, nil diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 756ad071..c4ade1b3 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -124,6 +124,9 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { {"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":"一月"},{"name":"一月"}]`}, `--sheets[1].name="一月" 与 --sheets[0] 重复`}, {"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":[...]}`}, @@ -197,6 +200,19 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { 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}]}]}`}, @@ -1060,3 +1076,48 @@ func TestPickNumRejectsNonIntegralAndOutOfRange(t *testing.T) { 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":"一月"},{"name":"一月"}]`, + `--sheets[1].name="一月" 与 --sheets[0] 重复`, + }, + { + // 非相邻重复也要报出首次出现的下标 + "duplicate-not-adjacent", + `[{"name":"a"},{"name":"b"},{"name":"a"}]`, + `--sheets[2].name="a" 与 --sheets[0] 重复`, + }, + { + "wrapped-form-also-checked", + `{"sheets":[{"name":"x"},{"name":"x"}]}`, + `--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":"一月"},{"name":"二月"}]`, + `[{"name":"Sheet"},{"name":"sheet"}]`, + `[{"name":"only"}]`, + } { + if _, err := parseCreateSheetSpecs(sheets); err != nil { + t.Errorf("parseCreateSheetSpecs(%s) 误拒: %v", sheets, err) + } + } +} From 115dad3b82445a4e1d993b5d1e269f48bae5d439 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 10:40:53 +0800 Subject: [PATCH 07/35] fix(sheet): keep JSON integer literals intact through both data channels P1 from automated review. --values decoded with plain json.Unmarshal, so every number became a float64 and integers beyond 2^53 were rounded before anything was written. The read-back only checks that the probe cell is non-empty, so the corruption was reported as a successful write. Order numbers and snowflake IDs are ordinary spreadsheet data. Measured before the fix: 1234567890123456789 -> 1234567890123456768 snowflake id, tail rewritten 12345678901234567890 -> 12345678901234567000 20-digit order number 9007199254740993 -> 9007199254740992 2^53+1 --sheets had the same defect, which the review did not mention: its records and data are forwarded verbatim to table_put, and the float64 round trip rewrote 1234567890123456789 as 1234567890123456800 before the request left the CLI. Both channels now decode with json.Decoder.UseNumber, and cellToString emits a json.Number through its String method so no float conversion happens on the way to CSV. --styles keeps plain Unmarshal on purpose: its numbers are font sizes and pixel dimensions, already constrained to int32 by pickNum, with no large-integer case. Tests assert the payload the CLI actually sends, not a recomputed decode: - TestSheetCreatePreservesLargeIntegerLiterals checks the csv argument of set_range_from_csv and the marshalled table_put arguments. Both halves also assert the rounded forms are absent, so removing UseNumber fails the test instead of passing on a lucky substring match. - TestCellToStringKeepsJSONNumberVerbatim covers large, negative, fractional and exponent literals, and keeps the existing float64 behaviour for other callers. The shared scriptedToolCaller keeps only the last call, and the write is the fourth of five, so the assertion needs an intermediate call. Rather than extend that shared helper, this adds a callRecorder local to this file: InitDeps takes the edition.ToolCaller interface, so embedding *scriptedToolCaller and overriding CallTool is enough. Changed-code coverage stays at 100.0000% (850 statements) per check-coverage-gate.sh --changed-only. --- internal/helpers/sheet_create_with_data.go | 17 +- .../helpers/sheet_create_with_data_test.go | 145 ++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 5176d4ac..42312fcd 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -31,7 +31,12 @@ func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, value // `--values null` 是合法 JSON 且解析出 nil,两者不等价。 useValues := valuesStr != "" if useValues { - if err := json.Unmarshal([]byte(valuesStr), &values); err != nil { + // 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) } // null / [] 都写不出任何数据,必须在建文档之前拒掉, @@ -165,7 +170,12 @@ func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, value // 接受 JSON 数组、{"sheets":[...]} 或单个 spec 对象。 func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { var payload any - if err := json.Unmarshal([]byte(sheetsStr), &payload); err != nil { + // 同 --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) } var arr []any @@ -692,6 +702,9 @@ func cellToString(v any) string { return t case bool: return strconv.FormatBool(t) + case json.Number: + // 原样输出字面量,避免任何浮点中转造成的精度损失 + return t.String() case float64: // 整数不带小数点 if t == float64(int64(t)) { diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index c4ade1b3..0923f8fd 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -2,7 +2,10 @@ package helpers import ( "context" + "encoding/json" "errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "io" "math" "os" "strings" @@ -1121,3 +1124,145 @@ func TestParseCreateSheetSpecsRejectsDuplicateNames(t *testing.T) { } } } + +// 普通 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}`}, + }}, 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 := newCreateCmdForTest(t) + 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) +} From 05d8c861775ce274e971ef276787a87d5c93c40b Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 14:57:38 +0800 Subject: [PATCH 08/35] policy(schema-compat): accept reviewed property clearing and mcp ref redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two compatibility carve-outs, written alongside the existing interface_type retirement allowance. Both cover declarative provenance metadata that nothing reads at runtime: the tool a leaf invokes is decided in the CLI source, so a stale interface_ref or property misinforms a reader rather than misrouting a call. Neither carve-out can mask a change to the surface callers depend on. Cleared property through a reviewed mapping exclusion. A leaf whose backing RPC moves to a nested payload has no honest flat property to publish. The two alternatives are 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. Accepted only when the old value was non-empty, the new value is empty, and the new value resolved through reviewed_mapping_exclusion. A redirect to a different non-empty value, a clearing by inference or native annotation, a clearing with no recorded source, and populating a previously empty property all stay incompatible. The exclusion table cannot be abused to wave arbitrary clearing through: internal/cli/schema_parameter_bindings.go verifies every parameter claiming an exclusion really does deliver an empty property, and every entry carries a non-empty reviewed reason. Redirected interface_ref with an unchanged CLI contract. Accepted only when interface_mode is unchanged and stays mcp, both refs are non-empty, and no other compatibility failure was recorded for that tool. That last condition is the operative definition of "the contract is unchanged" — it is measured, not asserted, so it automatically covers a lost parameter, a newly required one, a moved type / default / format / enum, a tightened constraint, a positional or dry_run change, and any effect / risk / confirmation / idempotency move. Any one of them re-reports the redirect, so a surface change cannot ride along behind a backend move. Moving to or from composite is a change in kind rather than a redirect and stays reported; so does removing a ref outright. Deliberately still incompatible: mcp -> composite with the ref dropped. That is a leaf declaring it now orchestrates several RPCs, which is a semantic upgrade rather than a like-for-like substitution, and it belongs in review. For this branch the two carve-outs take schema-compatibility from 17 changed fields to 4 — the twelve sheet.range_set_style property clearings and its update_range -> set_cell_range redirect are now accepted. The remaining four are the interface_mode plus interface_ref pairs on sheet.create_workspace_sheet and sheet.submit_export_job. Tests: TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion and TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect assert the accepted shapes plus twelve neighbouring shapes that must stay incompatible; the drift table gains cases for clearing without an exclusion and redirecting despite one. --- scripts/policy/schema-compat/main.go | 94 +++++++++++- scripts/policy/schema-compat/main_test.go | 176 ++++++++++++++++++++++ 2 files changed, 268 insertions(+), 2 deletions(-) diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 84447246..9c45f084 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,53 @@ 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(oldTool, newTool, failures) { + failures = append(failures, fmt.Sprintf("schema tool %q changed interface_ref", toolPath)) + } sort.Strings(failures) return failures } +// compatibleInterfaceRefRedirect accepts repointing a tool at a different +// backing RPC when 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. +// +// The carve-out is gated on the contract callers actually depend on: +// +// - 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(oldTool, newTool toolSchema, otherFailures []string) bool { + if oldTool.InterfaceMode != newTool.InterfaceMode || newTool.InterfaceMode != interfaceModeMCP { + return false + } + if 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 +791,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 +799,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..6ac83445 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -340,6 +340,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 +691,167 @@ 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". +func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) { + // 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 = `{"product_id":"sheet","rpc_name":"update_range"}` + }) + + // Accepted: mcp -> mcp, both refs non-empty, nothing else changed. + redirected := cloneContract(baseline) + mutateTool(&redirected, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` }) + if failures := checkCompatibility(baseline, redirected); len(failures) != 0 { + t.Fatalf("a pure 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 = `{"product_id":"sheet","rpc_name":"set_cell_range"}` + delete(tool.Parameters, "title") + }) + }}, + } { + 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) + } + }) + } +} From 7c9687094f44a6d6e2cd0b349cbd9c042f312d9c Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 16:53:08 +0800 Subject: [PATCH 09/35] refactor(sheet): declare create and export as mcp with their primary interface_ref Reverts the interface_mode of sheet create and sheet export from composite back to mcp with a single interface_ref, matching upstream/main and the existing convention for multi-tool leaves (doc.create_document declares mcp + create_document even though it also calls update_document). Rationale: - interface_ref is audit / traceability metadata; nothing reads it at runtime (verified: rebuilding with a bogus interface_ref still routes to the correct tool). Declaring the primary tool and treating the orchestration as an implementation detail is the pattern main already uses. - sheet export was mcp + submit_export_job on main; it already orchestrated submit_export_job + query_export_job without declaring the poll. Adding an --export-format csv branch does not change that shape, so it does not warrant flipping to composite. - sheet create was a genuine single-RPC command on main (create_workspace_sheet only). The --values / --sheets / --styles orchestration I added runs after the document exists; per the doc.create_document precedent it stays mcp. This takes the sheet schema-compatibility failures from 4 to 0 without a waiver or an admin override: the declarations now equal main's. Also updates the wording of the seven new mapping-exclusion reasons for these two commands (submit_export_job CSV params, create_workspace_sheet values/sheets/styles) from "Composite ... input" to "Wrapper ... input", so the reason text no longer collides with the interface_mode value now that both leaves are mcp. The reasons are otherwise unchanged and still describe where each value actually goes. range_batch_set_style keeps its "Composite" wording because it genuinely stays interface_mode=composite. Removes the two composite entries for these leaves from the interface disposition contract test. No execution path changes; targeted sheet suites, the disposition contract test, and the schema-compat policy tests all pass; check-schema-catalog is green; sheet-scoped schema-compatibility reports 0 failures. --- .../schema_interface_disposition_contract_test.go | 10 ---------- internal/cli/schema_parameter_mapping_ledger.go | 14 +++++++------- internal/helpers/sheet.go | 9 +++++++-- internal/helpers/sheet_workbook.go | 10 ++++++++-- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/internal/app/schema_interface_disposition_contract_test.go b/internal/app/schema_interface_disposition_contract_test.go index dbe70f58..3599a35f 100644 --- a/internal/app/schema_interface_disposition_contract_test.go +++ b/internal/app/schema_interface_disposition_contract_test.go @@ -36,16 +36,6 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) { mode: "composite", 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_workspace_sheet", - mode: "composite", - reason: "With --values / --sheets / --styles the CLI chains sheet/create_workspace_sheet, get_all_sheets, update_sheet, set_range_from_csv or table_put, a get_range_as_csv read-back, and set_cell_range / update_dimension / merge_cells; no single direct MCP interface represents the orchestration.", - }, - { - canonical: "sheet.submit_export_job", - mode: "composite", - reason: "The CLI routes by --export-format: xlsx submits sheet/submit_export_job and polls sheet/query_export_job, while csv reads sheet/get_range_as_csv synchronously. No single direct MCP interface covers both branches.", - }, { canonical: "sheet.range_read", mode: "composite", diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index eaec19ff..b700b2b4 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -555,9 +555,9 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.create_sheet_comment --node": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_sheet_comment --range": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_sheet_comment --sheet-id": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", - "sheet.create_workspace_sheet --sheets": "Composite orchestration input: the CLI feeds these typed tables to sheet/table_put after the workbook exists; create_workspace_sheet has no such property.", - "sheet.create_workspace_sheet --styles": "Composite orchestration input: the CLI expands this config into set_cell_range / update_dimension / merge_cells calls after the data write; create_workspace_sheet has no such property.", - "sheet.create_workspace_sheet --values": "Composite orchestration input: the CLI encodes this matrix as CSV for sheet/set_range_from_csv after the workbook exists; create_workspace_sheet has no such property.", + "sheet.create_workspace_sheet --sheets": "Wrapper orchestration input: the CLI feeds these typed tables to sheet/table_put after the workbook exists; create_workspace_sheet has no such property.", + "sheet.create_workspace_sheet --styles": "Wrapper orchestration input: the CLI expands this config into set_cell_range / update_dimension / merge_cells calls after the data write; create_workspace_sheet has no such property.", + "sheet.create_workspace_sheet --values": "Wrapper orchestration input: the CLI encodes this matrix as CSV for sheet/set_range_from_csv after the workbook exists; create_workspace_sheet has no such property.", "sheet.delete_cond_format --yes": "local confirmation control", "sheet.delete_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.delete_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", @@ -621,11 +621,11 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.", "sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", - "sheet.submit_export_job --export-format": "Composite router input: --export-format selects between the xlsx submit_export_job branch and the synchronous csv get_range_as_csv branch; it is not a property of either RPC.", + "sheet.submit_export_job --export-format": "Wrapper router input: --export-format selects between the xlsx submit_export_job path and the synchronous csv get_range_as_csv path; it is not a property of the declared submit_export_job interface.", "sheet.submit_export_job --output": "local destination consumed after export job completion", - "sheet.submit_export_job --range": "Composite branch input: only the csv branch consumes it, as the range argument of sheet/get_range_as_csv; the declared xlsx submit_export_job interface has no such property.", - "sheet.submit_export_job --sheet-id": "Composite branch input: only the csv branch consumes it, as the sheetId argument of sheet/get_range_as_csv; the declared xlsx submit_export_job interface has no such property.", - "sheet.submit_export_job --value-render-option": "Composite branch input: only the csv branch consumes it, as the valueRenderOption argument of sheet/get_range_as_csv; the declared xlsx submit_export_job interface has no such property.", + "sheet.submit_export_job --range": "Wrapper branch input: only the --export-format csv path consumes it, as the range argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", + "sheet.submit_export_job --sheet-id": "Wrapper branch input: only the --export-format csv path consumes it, as the sheetId argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", + "sheet.submit_export_job --value-render-option": "Wrapper branch input: only the --export-format csv path consumes it, as the valueRenderOption argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", "sheet.table_get --no-header": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --no-header is a CLI wrapper input and does not publish a direct interface property.", "sheet.table_get --range": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.table_get --sheet-id": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 4548514d..d4e81c64 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -143,10 +143,15 @@ func newSheetCommand() *cobra.Command { }, Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, + // interface_ref 声明主工具 submit_export_job,与 main 一致。xlsx 走 + // 提交+轮询(submit_export_job / query_export_job)、--export-format csv + // 走 get_range_as_csv,这些编排/分支是实现细节:轮询本就未在声明中体现 + // (main 亦如此),csv 分支同理。沿用既有的「声明主工具」惯例(参见 + // doc.create_document 同样多工具却声明 mcp),不因新增 csv 分支改判 composite。 Interface: &contract.InterfaceSpec{ - Mode: "composite", + Mode: "mcp", Availability: "available", - Reason: "The CLI routes by --export-format: xlsx submits sheet/submit_export_job and polls sheet/query_export_job, while csv reads sheet/get_range_as_csv synchronously. No single direct MCP interface covers both branches.", + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "submit_export_job"}, }, Selection: contract.SelectionSpec{ AgentSummary: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", diff --git a/internal/helpers/sheet_workbook.go b/internal/helpers/sheet_workbook.go index 258438bc..ec2b9afb 100644 --- a/internal/helpers/sheet_workbook.go +++ b/internal/helpers/sheet_workbook.go @@ -91,10 +91,16 @@ func newWorkbookCmds() []*cobra.Command { PrimaryCLIPath: "sheet create", }, Description: "创建钉钉在线电子表格文档(axls),返回 nodeId。", + // interface_ref 声明主工具 create_workspace_sheet,与 main 一致。带 + // --values / --sheets / --styles 时 CLI 会在建文档后编排 get_all_sheets / + // update_sheet / table_put / set_range_from_csv / get_range_as_csv / + // set_cell_range / update_dimension / merge_cells,但这些是建文档之后的 + // 实现细节。沿用既有的「声明主工具」惯例(参见 doc.create_document 同样在 + // create 后追加 update_document 却声明 mcp),不将 create 改判为 composite。 Interface: &contract.InterfaceSpec{ - Mode: "composite", + Mode: "mcp", Availability: "available", - Reason: "With --values / --sheets / --styles the CLI chains sheet/create_workspace_sheet, get_all_sheets, update_sheet, set_range_from_csv or table_put, a get_range_as_csv read-back, and set_cell_range / update_dimension / merge_cells; no single direct MCP interface represents the orchestration.", + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_workspace_sheet"}, }, Selection: contract.SelectionSpec{ AgentSummary: "创建钉钉在线电子表格文档(axls),返回 nodeId。", From d73e199d972abb033d2f5ec726197e4d6dc07d56 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 17:26:47 +0800 Subject: [PATCH 10/35] fix(sheet): read back each sheet after --sheets table_put to catch silent data loss The --values branch already reads back its first non-empty cell after writing, to defend against the new-document initialization race where a write returns success but the data does not land. The --sheets branch called table_put and reported success with no read-back, so the same race would let the command exit successfully while one or more sheets silently lost their initial data. After table_put, the --sheets branch now: - re-fetches get_all_sheets to build a name -> sheetId map (table_put reuses the renamed default sheet and auto-creates the rest by name), and - for every spec that actually has content, reads back its first expected non-empty cell and fails if the read-back is empty or the sheet is missing. firstNonEmptySheetSpecCell mirrors firstNonEmptyValuesCell: it treats columns as the header row followed by data rows, honours start_cell, and returns hasContent=false for a name-only spec so a legitimately empty sheet is not misreported as data loss. Failures carry the nodeId and point at sheet table-put for recovery, matching the --values branch's error shape. Tests: - TestSheetCreateWithSheetsVerifiesEachSheetLanded covers an empty read-back (errors, naming the sheet + nodeId + table-put), a sheet missing from the post-write listing, and a name-only sheet that must not trigger a read-back. - TestFirstNonEmptySheetSpecCell covers header/data origins, an empty first header cell, a start_cell offset, and content-less specs. - Existing --sheets tests updated for the added get_all_sheets + per-sheet read-back calls. --- internal/helpers/sheet_create_with_data.go | 86 ++++++++++++++ .../helpers/sheet_create_with_data_test.go | 108 +++++++++++++++++- 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 42312fcd..9bf7b68c 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -142,6 +142,27 @@ func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, value }); 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("初始数据写入完成。") @@ -749,6 +770,71 @@ func resolveFirstSheetID(ctx context.Context, nodeID string) (string, error) { 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 还原成写入的二维逻辑网格: +// columns(若非空)作为表头行,其后接 data 行。用于定位首个预期非空单元格。 +func sheetSpecGrid(spec map[string]any) [][]any { + var grid [][]any + if cols, ok := spec["columns"].([]any); ok && len(cols) > 0 { + 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) + } else { + grid = append(grid, []any{r}) + } + } + } + return grid +} + +// firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。 +// 尊重 start_cell 偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 +// 的内容(例如只给了 name),这类工作表本就应为空,调用方跳过回读,避免把合法的 +// 空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell 一致:不能死盯 +// A1,首行/首列为空的合法数据不应被误报。 +func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) { + col0, row0 := 1, 1 + if sc, ok := spec["start_cell"].(string); ok && sc != "" { + if c, r, err := parseA1Cell(strings.ToUpper(sc)); err == nil { + col0, row0 = c, r + } + } + 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 diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 0923f8fd..4c917ce7 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -295,6 +295,10 @@ func TestSheetCreateWithSheetsRenamesDefaultThenTablePut(t *testing.T) { {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": "报表", @@ -302,12 +306,102 @@ func TestSheetCreateWithSheetsRenamesDefaultThenTablePut(t *testing.T) { }); err != nil { t.Fatalf("create with sheets: %v", err) } - if caller.tool != "table_put" { - t.Fatalf("last tool = %q, want table_put", caller.tool) + // create → 探活 → 定位默认表 → 重命名 → table_put → 取 name 映射 → 两表各回读一次 + if caller.calls != 8 { + t.Fatalf("calls = %d, want 8", caller.calls) } - // --sheets 路径不做 A1 回读校验,因此比 --values 少一次调用 - if caller.calls != 5 { - t.Fatalf("calls = %d, want 5", 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) + } + + // 只有 name、无数据的工作表本就应为空,不回读、不报错 + 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":"空表"}]`, + }); 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","data":[[1]]}`, "A1", true}, // 无表头,纯 data + {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell 偏移 + {`{"name":"S"}`, "", false}, // 只有 name + {`{"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) + } } } @@ -921,6 +1015,8 @@ func TestSheetCreateSheetsAppliesStylesBySheetName(t *testing.T) { {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{ @@ -1166,6 +1262,8 @@ func TestSheetCreatePreservesLargeIntegerLiterals(t *testing.T) { {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 + `]]}]`, From dbecf23bc4f1c837a16fff9788941c013546b8ae Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 17:38:58 +0800 Subject: [PATCH 11/35] test(sheet): cover --sheets read-back error paths to reach 100% changed-code coverage Adds coverage for the branches introduced by the per-sheet read-back: resolveSheetIDsByName's RPC-error and unparseable-response paths, the create --sheets path surfacing a list-fetch failure with the nodeId, and the single-value data row in sheetSpecGrid. Changed-code coverage back to 100%. --- .../helpers/sheet_create_with_data_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 4c917ce7..a0901f2a 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "io" "math" @@ -394,6 +395,7 @@ func TestFirstNonEmptySheetSpecCell(t *testing.T) { {`{"name":"S","columns":["项目"],"data":[["房租"]]}`, "A1", true}, {`{"name":"S","columns":["",""],"data":[["","x"]]}`, "B2", true}, // 首列表头空 {`{"name":"S","data":[[1]]}`, "A1", true}, // 无表头,纯 data + {`{"name":"S","data":[5]}`, "A1", true}, // data 行是单值而非数组 {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell 偏移 {`{"name":"S"}`, "", false}, // 只有 name {`{"name":"S","columns":[],"data":[]}`, "", false}, // 空 @@ -405,6 +407,42 @@ func TestFirstNonEmptySheetSpecCell(t *testing.T) { } } +// 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 --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"]]`} From ecaa375be840ca74af8cd27a3c5656ee9d08a980 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 18:03:16 +0800 Subject: [PATCH 12/35] fix(sheet): validate merge range up front, fix startCell key, correct CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review P1s. 1. CHANGELOG no longer asserts a breaking Schema change this PR does not ship. sheet export / sheet create deliver main's mcp + interface_ref and schema-compatibility reports ok (0 changed fields), so the "declared as composite (breaking)" entry was false and is removed; the set-style entry drops the "breaking" framing (accepted as compatible by the reviewed mapping-exclusion carve-out); the duplicate ### Changed heading is merged. 2. firstNonEmptySheetSpecCell reads the start cell via pickStr(spec, "startCell", "start_cell"). Sheet specs are forwarded verbatim to table_put, whose wire fields are camelCase in this repo. The prior snake_case-only read meant a user passing the real startCell would have data written at the offset while the probe read A1 — a false "写入未生效" on a successful write. camelCase preferred, snake_case kept for tolerance. 3. planStyleOps now parses cell_merges range with parseA1Range, matching the cell_styles branch. planStyleOps is dry-run once before create_workspace_sheet as the up-front structural gate, so an invalid range like "not-a-range" is now rejected before any RPC instead of failing only at the final merge_cells call and leaving an unrollbackable partially-completed document. merge_cells' range contract is A1:B3-style, which parseA1Range covers (and it strips a Sheet1! prefix). Tests: cell-merges-invalid-range added to TestSheetCreateValidatesBeforeCreating Document (asserts calls == 0); TestFirstNonEmptySheetSpecCell covers both startCell and start_cell. Changed-code coverage stays at 100%; targeted sheet suites pass; CHANGELOG has a single Changed section with no false breaking claim. --- CHANGELOG.md | 6 +----- internal/helpers/sheet_create_with_data.go | 16 ++++++++++++++-- internal/helpers/sheet_create_with_data_test.go | 10 +++++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4520108a..19ac76ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Changed -- **`sheet range set-style` 后端切换为 `set_cell_range`(破坏性 Schema 变更)** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容,无 flag 删除。 +- **`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 export` / `sheet create` 声明为 composite(破坏性 Schema 变更)** — 两者都会按参数路由到多个工具(`--export-format csv` 走 `get_range_as_csv`;建表带数据串起建表→探活→写入→回读→样式),`mcp` 单一 ref 不准确。 - -### Changed - - **Doc import upload fallback** — `dws doc import` no longer fails on file formats outside the conversion whitelist (html, pdf, zip, extensionless, and any future format): it now hands the file to the document-space upload diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 9bf7b68c..94c839b7 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -580,6 +580,14 @@ func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error 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) @@ -814,13 +822,17 @@ func sheetSpecGrid(spec map[string]any) [][]any { } // firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。 -// 尊重 start_cell 偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 +// 尊重起始格偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 // 的内容(例如只给了 name),这类工作表本就应为空,调用方跳过回读,避免把合法的 // 空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell 一致:不能死盯 // A1,首行/首列为空的合法数据不应被误报。 +// +// 起始格键名以 table_put 线上契约的 camelCase startCell 为准(与本仓库 +// startCell / "startCell": "A1" 一致);同时兼容 snake_case start_cell,避免探针 +// 与实际写入位置错位导致「写成功却回读为空」的假阳性。 func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) { col0, row0 := 1, 1 - if sc, ok := spec["start_cell"].(string); ok && sc != "" { + if sc := pickStr(spec, "startCell", "start_cell"); sc != "" { if c, r, err := parseA1Cell(strings.ToUpper(sc)); err == nil { col0, row0 = c, r } diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index a0901f2a..41e3e0bd 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -250,6 +250,13 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { 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", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -396,7 +403,8 @@ func TestFirstNonEmptySheetSpecCell(t *testing.T) { {`{"name":"S","columns":["",""],"data":[["","x"]]}`, "B2", true}, // 首列表头空 {`{"name":"S","data":[[1]]}`, "A1", true}, // 无表头,纯 data {`{"name":"S","data":[5]}`, "A1", true}, // data 行是单值而非数组 - {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell 偏移 + {`{"name":"S","columns":["id"],"data":[[1]],"startCell":"C3"}`, "C3", true}, // startCell(table_put 线上 camelCase) + {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell(snake_case 兼容) {`{"name":"S"}`, "", false}, // 只有 name {`{"name":"S","columns":[],"data":[]}`, "", false}, // 空 } { From eb73b944a18b2da229370418243ee8f51b02bbc4 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 18:48:21 +0800 Subject: [PATCH 13/35] fix(sheet): reject row/column ranges with trailing characters up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseRowColRange gates --styles row_sizes/col_sizes before the document is created. The row branch parsed with fmt.Sscanf(a, "%d", &r1), which consumes only the leading digits and does not require the whole token, so "1x:3" was silently accepted as row 1 and "2foo" as row 2. Such input passed pre-flight, then update_dimension was sent to the wrong row after the document and data had already been created — an unrollbackable wrong edit, the opposite of the gate's purpose. The row branch now parses with strconv.Atoi, which requires the entire token to be a valid integer. The column branch had the same class of hole via a different path: parseA1Cell appends "1" to the column token, so "A5" became "A51" and was accepted as column A. A new isAllLetters pre-check requires the column token to be non-empty and letters-only before parsing; genuine multi-letter columns like "AX" still pass. With that guarantee the subsequent parseA1Cell can no longer fail, so its now-dead error branch is removed. Adds trailing-character rejection cases to TestParseRowColRange: "1x:3", "2foo", "1 2:3" (rows), "A5:C", "A1", ":C" (columns), plus "AX:C" to confirm multi-letter columns remain valid. Changed-code coverage stays at 100%. --- internal/helpers/sheet_create_with_data.go | 37 +++++++++++++++---- .../helpers/sheet_create_with_data_test.go | 11 ++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 94c839b7..2d415565 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -380,6 +380,21 @@ func cellStyleItemToSpec(item map[string]any) (*styleSpec, string, error) { 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) @@ -396,11 +411,13 @@ func parseRowColRange(addr string, isRow bool) (start string, length int, err er b = strings.TrimSpace(parts[1]) } if isRow { - var r1, r2 int - if _, e := fmt.Sscanf(a, "%d", &r1); e != nil || r1 < 1 { - return "", 0, fmt.Errorf("行范围非法: %s(应形如 \"1:3\")", addr) - } - if _, e := fmt.Sscanf(b, "%d", &r2); e != nil || r2 < 1 { + // 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 { @@ -408,11 +425,15 @@ func parseRowColRange(addr string, isRow bool) (start string, length int, err er } return fmt.Sprintf("%d", r1), r2 - r1 + 1, nil } - c1, _, e1 := parseA1Cell(strings.ToUpper(a) + "1") - c2, _, e2 := parseA1Cell(strings.ToUpper(b) + "1") - if e1 != nil || e2 != 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。 diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 41e3e0bd..44907ce4 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -812,6 +812,17 @@ func TestParseRowColRange(t *testing.T) { {"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) From 7eb39ad5d6e767700542158798df996d1d6f664f Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 21:05:47 +0800 Subject: [PATCH 14/35] fix(sheet): narrow the interface_ref carve-out, fail closed on truncated CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, plus a same-class defect found by sweeping for it. 1. compatibleInterfaceRefRedirect accepted any mcp tool repointing from any non-empty interface_ref to any other, as long as no other check for that tool failed. Schema shape cannot prove two RPCs share business semantics, permissions, error behaviour, or side effects, so that would have let every future backend swap bypass the gate it exists to enforce. It is now keyed on an explicit reviewedInterfaceRefRedirect allowlist of exact tool + old→new pairs, currently holding only the reviewed sheet.range_set_style: update_range → set_cell_range migration. Every other ref change is reported again. 2. sheet export --export-format csv only printed a stderr warning when get_range_as_csv returned hasMore=true, then wrote --output and reported success with exit code 0. Automated callers, and anyone not watching stderr, would treat an incomplete file as a complete export, and an existing target file was overwritten with truncated data. Truncation now fails before the write (leaving any existing file untouched) unless --allow-truncated is passed; with the opt-in the success line states the data is incomplete. --allow-truncated is registered in the reviewed mapping ledger as a local policy input. 3. Swept for the same classes and found sheet_dimension.go repeating the fmt.Sscanf("%d") prefix-parse hole in three places: insert_dimension, delete_dimension, and update_dimension all accepted --length "3x" as 3, so a malformed value silently operated on the wrong row/column count — the delete direction is not rollbackable. All three now use strconv.Atoi. (Checked and cleared: sheet csv-get also surfaces hasMore, but it has no --output and only returns the flag in its JSON payload, so it is not the same fail-open shape.) Tests: allowlist rejection cases (unreviewed target ref, and the same pair on a tool absent from the allowlist, asserted outside the table so the registration survives until checkCompatibility runs); truncation fail-closed with a pre-existing output file asserted byte-for-byte unchanged; --allow-truncated write-through; and an untruncated read needing no opt-in. Changed-code coverage stays at 100% (939 statements). --- .../cli/schema_parameter_mapping_ledger.go | 1 + internal/helpers/sheet_dimension.go | 22 ++++-- internal/helpers/sheet_export.go | 18 ++++- internal/helpers/sheet_export_csv_test.go | 74 ++++++++++++++++++- scripts/policy/schema-compat/main.go | 35 ++++++++- scripts/policy/schema-compat/main_test.go | 51 +++++++++++-- 6 files changed, 183 insertions(+), 18 deletions(-) diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index b700b2b4..e4ce345f 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -621,6 +621,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.", "sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", + "sheet.submit_export_job --allow-truncated": "local truncation policy: gates whether an incomplete csv read may still be written; not a property of any RPC.", "sheet.submit_export_job --export-format": "Wrapper router input: --export-format selects between the xlsx submit_export_job path and the synchronous csv get_range_as_csv path; it is not a property of the declared submit_export_job interface.", "sheet.submit_export_job --output": "local destination consumed after export job completion", "sheet.submit_export_job --range": "Wrapper branch input: only the --export-format csv path consumes it, as the range argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", diff --git a/internal/helpers/sheet_dimension.go b/internal/helpers/sheet_dimension.go index c3f495a4..4ce8d7ba 100644 --- a/internal/helpers/sheet_dimension.go +++ b/internal/helpers/sheet_dimension.go @@ -3,6 +3,7 @@ package helpers import ( "encoding/json" "fmt" + "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" @@ -51,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 { @@ -436,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 { @@ -531,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 { diff --git a/internal/helpers/sheet_export.go b/internal/helpers/sheet_export.go index b27a67f3..61ca677d 100644 --- a/internal/helpers/sheet_export.go +++ b/internal/helpers/sheet_export.go @@ -316,6 +316,7 @@ func newExportCmd() *cobra.Command { exportCmd.Flags().String("sheet-id", "", "工作表 ID 或名称(--export-format csv 时指定要导出的工作表,不传则第一个)") exportCmd.Flags().String("range", "", "导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出)") exportCmd.Flags().String("value-render-option", "", "取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula") + exportCmd.Flags().Bool("allow-truncated", false, "允许 CSV 被截断时仍然导出(仅 --export-format csv)。默认截断即报错并不写文件,避免不完整数据被当成完整导出") return exportCmd } @@ -338,6 +339,7 @@ func runSheetExportCsv(cmd *cobra.Command) error { 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") @@ -377,8 +379,18 @@ func runSheetExportCsv(cmd *cobra.Command) error { return err } + // 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0, + // 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标 + // 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果 + // 必须显式加 --allow-truncated。 + if hasMore && !allowTruncated { + return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+ + "请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 --export-format xlsx 导出完整表格,"+ + "或确认可接受不完整数据后加 --allow-truncated", + firstNonEmpty(outputPath, "stdout")) + } if hasMore { - deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断。" + + deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" + "请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 --export-format xlsx 导出完整表格。") } @@ -392,6 +404,10 @@ func runSheetExportCsv(cmd *cobra.Command) error { if err := os.WriteFile(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 } diff --git a/internal/helpers/sheet_export_csv_test.go b/internal/helpers/sheet_export_csv_test.go index 1702f790..5932fd5a 100644 --- a/internal/helpers/sheet_export_csv_test.go +++ b/internal/helpers/sheet_export_csv_test.go @@ -72,8 +72,10 @@ func TestSheetExportCsvForwardsRangeAndWarnsOnTruncation(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{ {text: `{"csv":"a,b\n","hasMore":true}`}, }} + // 截断默认失败,所以要显式放行才能走到落盘/输出。 if err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "range", "A1:Z1000"); err != nil { + "node", "NODE", "export-format", "csv", "range", "A1:Z1000", + "allow-truncated", "true"); err != nil { t.Fatalf("csv export: %v", err) } if got := caller.args["range"]; got != "A1:Z1000" { @@ -85,6 +87,73 @@ func TestSheetExportCsvForwardsRangeAndWarnsOnTruncation(t *testing.T) { } } +// 截断必须 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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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"}`}}} @@ -189,7 +258,8 @@ func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) { deps.Out.w, deps.Out.errW = &stdout, &stderr cmd := newExportCmd() - for _, kv := range [][2]string{{"node", "NODE"}, {"export-format", "csv"}} { + // 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。 + for _, kv := range [][2]string{{"node", "NODE"}, {"export-format", "csv"}, {"allow-truncated", "true"}} { if err := cmd.Flags().Set(kv[0], kv[1]); err != nil { t.Fatalf("set --%s: %v", kv[0], err) } diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 9c45f084..c58b989d 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -532,15 +532,36 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri // 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(oldTool, newTool, failures) { + !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. 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": {"update_range": "set_cell_range"}, +} + // compatibleInterfaceRefRedirect accepts repointing a tool at a different -// backing RPC when the CLI-facing contract is provably unchanged. +// 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 @@ -549,7 +570,10 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri // the ref or to keep publishing a name that no longer matches the request being // sent. // -// The carve-out is gated on the contract callers actually depend on: +// 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. @@ -565,13 +589,16 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri // 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(oldTool, newTool toolSchema, otherFailures []string) bool { +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 } diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index 6ac83445..9d37f1d2 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -764,19 +764,29 @@ func TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion(t *testing.T // 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". 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 = `{"product_id":"sheet","rpc_name":"update_range"}` + tool.InterfaceRef = oldRef }) - // Accepted: mcp -> mcp, both refs non-empty, nothing else changed. + // Accepted: mcp -> mcp, both refs non-empty, the pair is reviewed, nothing + // else changed. redirected := cloneContract(baseline) - mutateTool(&redirected, func(tool *toolSchema) { tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` }) + mutateTool(&redirected, func(tool *toolSchema) { tool.InterfaceRef = newRef }) if failures := checkCompatibility(baseline, redirected); len(failures) != 0 { - t.Fatalf("a pure interface_ref redirect should pass: %v", failures) + t.Fatalf("a reviewed interface_ref redirect should pass: %v", failures) } // Paired with a reviewed property clearing, which is the realistic shape: @@ -834,10 +844,19 @@ func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) { }}, {"a parameter was dropped", func(contract *schemaContract) { mutateTool(contract, func(tool *toolSchema) { - tool.InterfaceRef = `{"product_id":"sheet","rpc_name":"set_cell_range"}` + 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) @@ -854,4 +873,26 @@ func TestCrossPlatformCoverageSchemaCompatInterfaceRefRedirect(t *testing.T) { } }) } + + // 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) + } + }) } From 7405825294021d60b619ec0ac5be066ffe083d04 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 21:37:23 +0800 Subject: [PATCH 15/35] fix(policy): register the reviewed interface_ref redirect in its canonical form The allowlist added in the previous commit keyed the reviewed sheet.range_set_style migration by bare RPC name ("update_range"), but interface_ref holds the canonicalized JSON that parseTool produces via canonicalRawJSON. The lookup therefore never matched, the carve-out was effectively disabled, and the real gate failed with `schema tool "sheet/sheet.range_set_style" changed interface_ref`. The existing redirect test did not catch this: it registered the fixture entry using its own value and then asserted with the same value, so any format would have passed. That is the same mistake as keying a probe on the author's field spelling instead of the wire contract. - The allowlist entry now uses the compact canonical JSON, taken from the gate's own output rather than from pretty-printed `dws schema`. - TestCrossPlatformCoverageReviewedRedirectKeysAreCanonicalJSON recomputes every registered key through canonicalRawJSON, so a bare name or a pretty-printed variant fails locally instead of only in CI. sheet export keeps its reviewed mcp + submit_export_job declaration. The comment now records the known trade-off explicitly: --export-format csv is a mutually exclusive branch that reads get_range_as_csv and never invokes the declared submit_export_job, so this declaration does not cover the csv branch's backing interface. Attributing that branch is left out of scope for this change. Verified against the real gate, not just unit tests: all four Interface Integrity checks pass (authoritative-interface-integrity, check-command-compatibility, schema-compatibility, skill-command-integrity). --- internal/helpers/sheet.go | 11 +++---- scripts/policy/schema-compat/main.go | 10 +++++-- scripts/policy/schema-compat/main_test.go | 35 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index d4e81c64..d5a02948 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -143,11 +143,12 @@ func newSheetCommand() *cobra.Command { }, Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, - // interface_ref 声明主工具 submit_export_job,与 main 一致。xlsx 走 - // 提交+轮询(submit_export_job / query_export_job)、--export-format csv - // 走 get_range_as_csv,这些编排/分支是实现细节:轮询本就未在声明中体现 - // (main 亦如此),csv 分支同理。沿用既有的「声明主工具」惯例(参见 - // doc.create_document 同样多工具却声明 mcp),不因新增 csv 分支改判 composite。 + // interface_ref 声明 xlsx 主路径的 submit_export_job,与 main 一致。 + // 已知取舍:--export-format csv 是互斥分支,直接读 get_range_as_csv, + // 该分支下 submit_export_job 不执行,因此本声明未覆盖 csv 分支的后端接口。 + // 保持 mcp 是为了不改动 main 已有的声明(interface_ref 为审计元数据、 + // 运行时不消费,实测改成任意值仍按 CLI 源码正确路由),csv 分支的接口 + // 归属另行处理,不在本次改动范围内。 Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index c58b989d..d186a6c1 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -546,8 +546,10 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri // 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. Adding an -// entry is a contract decision and belongs in review, not in a feature change. +// 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 @@ -555,7 +557,9 @@ var reviewedInterfaceRefRedirect = map[string]map[string]string{ // 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": {"update_range": "set_cell_range"}, + "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 diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index 9d37f1d2..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" @@ -763,6 +764,40 @@ func TestCrossPlatformCoverageSchemaCompatPropertyClearingExclusion(t *testing.T // 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 From b566afe3e22112ab34b30a277eee8f343f776ae4 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 22:01:49 +0800 Subject: [PATCH 16/35] docs(changelog): record the csv-branch interface_ref limitation on sheet export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --export-format csv branch reads get_range_as_csv, but the sheet export leaf still declares interface_ref: submit_export_job, so discovering the csv capability through Schema yields the wrong backing interface. Audit metadata only — interface_ref is not read at runtime and routing is unaffected. Recorded here so the limitation reaches release notes rather than living only in a code comment; accurate attribution is tracked as follow-up. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ac76ac..0b002283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Added - **`sheet create` 支持建表即写入数据与样式** — 新增 `--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`),字段名对齐飞书 snake_case。所有结构与枚举在创建文档之前校验,非法配置不会留下白建的空文档。 -- **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名),不传则打印到 stdout。数据超出单次读取上限时截断并告警。 +- **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。已知限制:该分支实际读 `get_range_as_csv`,但 `sheet export` 叶子仍声明 `interface_ref: submit_export_job`,通过 Schema 查询 csv 能力会得到不准确的后端接口(仅影响审计元数据,不影响执行)。 - **`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`(四边边框)。 From a097d5751068f8094146a37df344445b2aabb553 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Fri, 7 Aug 2026 23:37:15 +0800 Subject: [PATCH 17/35] fix(sheet): validate the full --sheets contract before creating the document sheet create promises that every structural check happens before the first MCP request, but each sheet spec was only checked for object type and name: columns/data/dtypes/formats/startCell were left to table_put, so a bad type created and renamed the remote document first and failed at write time, leaving behind a document the user never successfully asked for. Validate every provided field against table_put's input contract up front, and reject the malformed {"sheets":"bad"} wrapper instead of treating it as one spec. Also fixed while auditing the same flow: - unknown/misspelled keys are now rejected in both --sheets and --styles. The server DTOs are fixed beans, so a stray "datas" was silently dropped and the read-back probe landed on the header row: full data loss reported as success. Near-miss spellings get the canonical key in the message. - columns is required (the server requires it), non-blank and trim-unique; dtypes/formats keys must resolve to a column, since the server looks them up by trimmed name and silently ignores the rest. - sheetId inside a spec is rejected: the document does not exist yet. - the read-back probe now honours header:false, mode:append (a fresh sheet appends at row 1, the startCell row is ignored) and $-absolute/lowercase startCell refs, which the server accepts after uppercasing. - --values cells must be scalars; a map used to be written as "map[a:1]". - the 30000-cell and 2000000-char write limits are enforced locally. Docs: the --styles top level only accepts snake_case (camelCase aliases are inner-field only), and the read-back probe is not pinned to A1. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- internal/helpers/sheet_create_with_data.go | 422 +++++++++++++++++- .../helpers/sheet_create_with_data_test.go | 208 ++++++++- internal/helpers/sheet_workbook.go | 14 +- .../products/sheet/sheet-workbook.md | 15 +- .../references/sheet/sheet-workbook.md | 15 +- 6 files changed, 620 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b002283..38d84e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Added -- **`sheet create` 支持建表即写入数据与样式** — 新增 `--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`),字段名对齐飞书 snake_case。所有结构与枚举在创建文档之前校验,非法配置不会留下白建的空文档。 +- **`sheet create` 支持建表即写入数据与样式** — 新增 `--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 export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。已知限制:该分支实际读 `get_range_as_csv`,但 `sheet export` 叶子仍声明 `interface_ref: submit_export_job`,通过 Schema 查询 csv 能力会得到不准确的后端接口(仅影响审计元数据,不影响执行)。 - **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。 - **`sheet replace --match-formula`** — 在公式文本中查找替换。 diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 2d415565..c47462f3 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -8,9 +8,11 @@ import ( "fmt" "math" "regexp" + "sort" "strconv" "strings" "time" + "unicode/utf8" "github.com/spf13/cobra" ) @@ -44,9 +46,15 @@ func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, value 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 { @@ -204,7 +212,14 @@ func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { case []any: arr = v case map[string]any: - if s, ok := v["sheets"].([]any); ok { + // 只要出现 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 对象 @@ -222,8 +237,13 @@ func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { 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 name == "" { + if strings.TrimSpace(name) == "" { return nil, fmt.Errorf("--sheets[%d] 缺少必填的 name 字段(创建带数据时每个工作表必须命名)", i) } // 工作表名不能重复:样式接口按「ID 或名称」定位工作表,重名时命中哪一张 @@ -233,21 +253,358 @@ func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { 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` 会变成「只写了表头、数据全丢」,而回读探针刚好落在表头上, +// 整条命令报成功。 +// +// 因此把契约里的每个已提供字段都在发第一个请求之前验完。 +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),但对 sheet create + // 没有意义:文档此刻还不存在,任何 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 落在空表上也写,而 sheet create 的工作表都是新建空表)。 +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 --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 写法。 +// "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 { @@ -347,6 +704,9 @@ func feishuMergeType(v string) (string, error) { // 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") @@ -465,6 +825,18 @@ func parseCreateStyles(stylesStr string, expectedNames []string) ([]sheetStyleOp 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) @@ -526,6 +898,9 @@ func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error 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) @@ -597,6 +972,9 @@ func planStyleOps(nodeID, sheetID string, ops sheetStyleOps) ([]styleCall, error } 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) @@ -824,18 +1202,17 @@ func resolveSheetIDsByName(ctx context.Context, nodeID string) (map[string]strin } // sheetSpecGrid 把 table_put 的一个 sheet spec 还原成写入的二维逻辑网格: -// columns(若非空)作为表头行,其后接 data 行。用于定位首个预期非空单元格。 +// 表头行(写表头时)在前,其后接 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 { + 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) - } else { - grid = append(grid, []any{r}) } } } @@ -844,20 +1221,25 @@ func sheetSpecGrid(spec map[string]any) [][]any { // firstNonEmptySheetSpecCell 返回该 sheet spec 首个预期非空单元格的 A1 地址。 // 尊重起始格偏移(默认 A1)。第二返回值为 false 表示该 spec 没有任何要写入 -// 的内容(例如只给了 name),这类工作表本就应为空,调用方跳过回读,避免把合法的 -// 空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell 一致:不能死盯 -// A1,首行/首列为空的合法数据不应被误报。 +// 的内容(例如 header=false 且没给 data),这类工作表本就应为空,调用方跳过回读, +// 避免把合法的空表误判为数据丢失。逻辑与 --values 分支的 firstNonEmptyValuesCell +// 一致:不能死盯 A1,首行/首列为空的合法数据不应被误报。 // -// 起始格键名以 table_put 线上契约的 camelCase startCell 为准(与本仓库 -// startCell / "startCell": "A1" 一致);同时兼容 snake_case start_cell,避免探针 -// 与实际写入位置错位导致「写成功却回读为空」的假阳性。 +// 起始格键名只认 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 := pickStr(spec, "startCell", "start_cell"); sc != "" { - if c, r, err := parseA1Cell(strings.ToUpper(sc)); err == nil { + if sc := normalizeSpecStartCell(pickStr(spec, "startCell")); sc != "" { + if c, r, err := parseA1Cell(sc); err == nil { col0, row0 = c, r } } + // mode=append 时服务端按「已有数据的下一行」定位:sheet create 新建的工作表 + // 都是空表,追加就从第 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) != "" { diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 44907ce4..c07cc75c 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -130,7 +130,49 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { {"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":"一月"},{"name":"一月"}]`}, `--sheets[1].name="一月" 与 --sheets[0] 重复`}, + {"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":[...]}`}, @@ -138,12 +180,12 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { {"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"},{"name":"B"}]`, "styles": `[{"name":"A","cell_merges":[{"range":"A1:B1"}]}]`}, + 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"}]`, "styles": `[{"name":"Z","cell_merges":[{"range":"A1:B1"}]}]`}, + map[string]string{"name": "X", "sheets": `[{"name":"A","columns":["a"]}]`, "styles": `[{"name":"Z","cell_merges":[{"range":"A1:B1"}]}]`}, "不一致(需顺序对应)", }, { @@ -257,6 +299,59 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { 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 /`, + }, + { + // 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) { @@ -272,6 +367,65 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { } } +// 写入规模上限是服务端硬限(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 { @@ -365,7 +519,7 @@ func TestSheetCreateWithSheetsVerifiesEachSheetLanded(t *testing.T) { t.Fatalf("缺失工作表应带 nodeId 报错: %v", err) } - // 只有 name、无数据的工作表本就应为空,不回读、不报错 + // header=false 且没给 data 的工作表本就应为空,不回读、不报错 empty := &scriptedToolCaller{steps: []scriptedToolStep{ {text: `{"nodeId":"NODE_1"}`}, {text: `{"sheets":[{"sheetId":"SHEET_1","name":"Sheet1"}]}`}, @@ -377,7 +531,7 @@ func TestSheetCreateWithSheetsVerifiesEachSheetLanded(t *testing.T) { }} if err := runCreate(t, empty, map[string]string{ "name": "报表", - "sheets": `[{"name":"有数据","columns":["a"],"data":[["v"]]},{"name":"空表"}]`, + "sheets": `[{"name":"有数据","columns":["a"],"data":[["v"]]},{"name":"空表","columns":["a"],"header":false}]`, }); err != nil { t.Fatalf("无数据工作表不应触发回读失败: %v", err) } @@ -400,13 +554,20 @@ func TestFirstNonEmptySheetSpecCell(t *testing.T) { wantContent bool }{ {`{"name":"S","columns":["项目"],"data":[["房租"]]}`, "A1", true}, - {`{"name":"S","columns":["",""],"data":[["","x"]]}`, "B2", true}, // 首列表头空 - {`{"name":"S","data":[[1]]}`, "A1", true}, // 无表头,纯 data - {`{"name":"S","data":[5]}`, "A1", true}, // data 行是单值而非数组 - {`{"name":"S","columns":["id"],"data":[[1]],"startCell":"C3"}`, "C3", true}, // startCell(table_put 线上 camelCase) - {`{"name":"S","columns":["id"],"data":[[1]],"start_cell":"C3"}`, "C3", true}, // start_cell(snake_case 兼容) - {`{"name":"S"}`, "", false}, // 只有 name - {`{"name":"S","columns":[],"data":[]}`, "", false}, // 空 + {`{"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 { @@ -994,9 +1155,14 @@ func TestCallMCPToolSilentSuppressesOutput(t *testing.T) { func TestParseCreateSheetSpecsAcceptedShapes(t *testing.T) { for _, raw := range []string{ - `[{"name":"A"},{"name":"B"}]`, - `{"sheets":[{"name":"A"}]}`, - `{"name":"A"}`, // 单个 spec 对象 + `[{"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 { @@ -1243,18 +1409,18 @@ func TestParseCreateSheetSpecsRejectsDuplicateNames(t *testing.T) { }{ { "two-identical", - `[{"name":"一月"},{"name":"一月"}]`, + `[{"name":"一月","columns":["a"]},{"name":"一月","columns":["a"]}]`, `--sheets[1].name="一月" 与 --sheets[0] 重复`, }, { // 非相邻重复也要报出首次出现的下标 "duplicate-not-adjacent", - `[{"name":"a"},{"name":"b"},{"name":"a"}]`, + `[{"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"},{"name":"x"}]}`, + `{"sheets":[{"name":"x","columns":["a"]},{"name":"x","columns":["a"]}]}`, `--sheets[1].name="x" 与 --sheets[0] 重复`, }, } { @@ -1268,9 +1434,9 @@ func TestParseCreateSheetSpecsRejectsDuplicateNames(t *testing.T) { // 名称不同照常放行。仅大小写不同不算重复:服务端区分大小写,CLI 不该收紧。 for _, sheets := range []string{ - `[{"name":"一月"},{"name":"二月"}]`, - `[{"name":"Sheet"},{"name":"sheet"}]`, - `[{"name":"only"}]`, + `[{"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) diff --git a/internal/helpers/sheet_workbook.go b/internal/helpers/sheet_workbook.go index ec2b9afb..63b37ff3 100644 --- a/internal/helpers/sheet_workbook.go +++ b/internal/helpers/sheet_workbook.go @@ -17,13 +17,19 @@ func newWorkbookCmds() []*cobra.Command { 初始数据(--values 与 --sheets 二选一,均为可选): --values 二维 JSON 数组,裸值写入默认工作表 A1 起(单表快速建表,无表头/类型语义, - 复用 csv-put 通道,自动识别数字/布尔) + 复用 csv-put 通道,自动识别数字/布尔)。单元格只能是字符串/数字/布尔/null; + 上限 30000 单元格、编码为 CSV 后 2000000 字符 --sheets typed table 数组,多工作表一次写入(复用 table-put 通道)。每个条目形如 {"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]} - name 必填;第一个条目写入默认工作表(自动重命名为其 name),其余按 name 自动新建。 - 支持列类型(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,可选,需与 --values 或 --sheets 搭配;字段名对齐飞书 snake_case): +样式配置(--styles,可选,需与 --values 或 --sheets 搭配;顶层键对齐飞书 snake_case, +列表项内字段兼容 camelCase;两级都拒绝未知键,避免写错的键被静默丢弃导致样式只应用一半): {"styles":[{"name":"表名", "cell_styles":[{"range":"A1:D1","font_weight":"bold","background_color":"#FFF2CC", "font_family":"微软雅黑","number_format":"@", diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index 59c4df69..00cf30d1 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -54,12 +54,17 @@ Flags: **创建时写入初始数据**(`--values` 与 `--sheets` 二选一,都不传则创建空表): -- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。 +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 - `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; - `name` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 + `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): +**建表时一并应用样式**(`--styles`,顶层键对齐飞书 snake_case,列表项内字段兼容 camelCase;两级都拒绝未知键): ```json {"styles":[{"name":"表名", @@ -77,9 +82,9 @@ Flags: - `merge_type` 取 `all` / `rows` / `columns` **行为要点**: -- 所有 JSON 结构与枚举都在**创建文档之前**校验,非法配置直接失败,不会留下白建的空文档 +- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 - 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 -- `--values` 写完会回读 A1 校验确实落盘;若未落盘会报错并提示用 `csv-put` / `range update` 补写 +- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 - 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 示例: diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index 16e9ce96..120b18e0 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -56,12 +56,17 @@ Flags: **创建时写入初始数据**(`--values` 与 `--sheets` 二选一,都不传则创建空表): -- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。 +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 - `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; - `name` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 + `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): +**建表时一并应用样式**(`--styles`,顶层键对齐飞书 snake_case,列表项内字段兼容 camelCase;两级都拒绝未知键): ```json {"styles":[{"name":"表名", @@ -79,9 +84,9 @@ Flags: - `merge_type` 取 `all` / `rows` / `columns` **行为要点**: -- 所有 JSON 结构与枚举都在**创建文档之前**校验,非法配置直接失败,不会留下白建的空文档 +- 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 - 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 -- `--values` 写完会回读 A1 校验确实落盘;若未落盘会报错并提示用 `csv-put` / `range update` 补写 +- 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 - 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 示例: From e026c754e09242bbbb93ce2af001b0c081ebda47 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sat, 8 Aug 2026 00:01:47 +0800 Subject: [PATCH 18/35] fix(sheet): require EOF after the --values / --sheets JSON value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json.Decoder.Decode returns after one value, so `--values '[[1]] trailing'` was accepted as a valid matrix and the document got created anyway. A paste that ran long, leftover shell concatenation, or two JSON values glued together would silently drop the tail and still create a document the user never asked for — and creation cannot be rolled back atomically. Require EOF after the first value on both flags, following decodeOARequest. docs(sheet): document the fail-closed CSV export and --allow-truncated The skill references still claimed an oversized table is truncated with a warning on stderr, and omitted the flag. The command now aborts before writing anything when the server reports hasMore, so an agent relying on the skill would misread the result and had no way to learn how to opt in. Co-Authored-By: Claude Opus 5 --- internal/helpers/sheet_create_with_data.go | 22 +++++++++++++++++++ .../helpers/sheet_create_with_data_test.go | 6 +++++ .../references/products/sheet/sheet-export.md | 13 +++++++++-- .../references/sheet/sheet-export.md | 13 +++++++++-- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/helpers/sheet_create_with_data.go b/internal/helpers/sheet_create_with_data.go index c47462f3..334f237e 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -5,7 +5,9 @@ import ( "context" "encoding/csv" "encoding/json" + "errors" "fmt" + "io" "math" "regexp" "sort" @@ -41,6 +43,9 @@ func runCreateSheetWithData(cmd *cobra.Command, createArgs map[string]any, value 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 { @@ -207,6 +212,9 @@ func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { 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: @@ -279,6 +287,20 @@ func parseCreateSheetSpecs(sheetsStr string) ([]map[string]any, error) { // 整条命令报成功。 // // 因此把契约里的每个已提供字段都在发第一个请求之前验完。 + +// 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)。 diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index c07cc75c..1af86612 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -116,6 +116,10 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { 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 不能为空"}, @@ -124,6 +128,8 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { // 全空矩阵写不出任何数据,同样必须在建文档之前拒掉 {"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]`}, "不是对象"}, diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index fc4454c0..828d58e4 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -40,6 +40,7 @@ Flags: --sheet-id string 工作表 ID 或名称(仅 --export-format csv,不传则第一个) --range string 导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出) --value-render-option string 取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula + --allow-truncated 允许 CSV 被截断时仍然导出(仅 --export-format csv)。默认截断即报错且不写文件 ``` 将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 @@ -49,7 +50,12 @@ Flags: 2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 -**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` 生效。不传 `--output` 时 CSV 正文打印到 stdout。超大表会截断并在 stderr 给出警告,此时用 `--range` 分块导出或改用默认的 xlsx。 +**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` / `--allow-truncated` 生效。不传 `--output` 时 CSV 正文打印到 stdout。 + +**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: +- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) +- 改用默认的 `--export-format xlsx` 导出完整表格 +- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格" ```bash # 落盘到本地 @@ -58,8 +64,11 @@ dws sheet export --node --export-format csv --sheet-id --ou # 输出到 stdout 便于管道处理 dws sheet export --node --export-format csv --sheet-id -# 大表分块导出(避免截断) +# 大表分块导出(避免截断;不分块时默认会因截断而报错) dws sheet export --node --export-format csv --sheet-id --range "A1:Z1000" --output ./part1.csv + +# 明确接受不完整数据(否则截断即失败) +dws sheet export --node --export-format csv --sheet-id --allow-truncated --output ./partial.csv ``` 注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用默认的 xlsx。 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md index c9dc23c9..6e065c87 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -40,6 +40,7 @@ Flags: --sheet-id string 工作表 ID 或名称(仅 --export-format csv,不传则第一个) --range string 导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出) --value-render-option string 取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula + --allow-truncated 允许 CSV 被截断时仍然导出(仅 --export-format csv)。默认截断即报错且不写文件 ``` 将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 @@ -49,7 +50,12 @@ Flags: 2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 -**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` 生效。不传 `--output` 时 CSV 正文打印到 stdout。超大表会截断并在 stderr 给出警告,此时用 `--range` 分块导出或改用默认的 xlsx。 +**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` / `--allow-truncated` 生效。不传 `--output` 时 CSV 正文打印到 stdout。 + +**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: +- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) +- 改用默认的 `--export-format xlsx` 导出完整表格 +- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格" ```bash # 落盘到本地 @@ -58,8 +64,11 @@ dws sheet export --node --export-format csv --sheet-id --ou # 输出到 stdout 便于管道处理 dws sheet export --node --export-format csv --sheet-id -# 大表分块导出(避免截断) +# 大表分块导出(避免截断;不分块时默认会因截断而报错) dws sheet export --node --export-format csv --sheet-id --range "A1:Z1000" --output ./part1.csv + +# 明确接受不完整数据(否则截断即失败) +dws sheet export --node --export-format csv --sheet-id --allow-truncated --output ./partial.csv ``` 注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用默认的 xlsx。 From 3d6e62fc08635900c3901cb48d7d85c1e8419886 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sat, 8 Aug 2026 00:17:45 +0800 Subject: [PATCH 19/35] docs(sheet): document the batch style caps and --styles size rules Sweeping the same class the last review round hit: limits and behaviour this PR added that only reached the Go long help, not the skill references an agent actually reads. - batch-set-style: the 200000-cell cumulative cap across all ranges was missing (the 100-range cap and the atomic rollback were already there). - sheet create --styles: size must be a positive integer (a fraction is rejected rather than silently truncated) and the row/column range forms reject trailing characters. Co-Authored-By: Claude Opus 5 --- skills/mono/references/products/sheet/sheet-style-format.md | 1 + skills/mono/references/products/sheet/sheet-workbook.md | 1 + .../multi/dingtalk-misc/references/sheet/sheet-style-format.md | 1 + skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md | 1 + 4 files changed, 4 insertions(+) diff --git a/skills/mono/references/products/sheet/sheet-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md index dcfc1a3d..7f07dfb9 100644 --- a/skills/mono/references/products/sheet/sheet-style-format.md +++ b/skills/mono/references/products/sheet/sheet-style-format.md @@ -159,6 +159,7 @@ Flags: **特性说明**: - CLI 把所有区域组装为**一次** `batch_update` 调用,由**服务端原子执行**:默认严格事务,任一项失败则**整批回滚**(不会出现部分区域已改、部分未改) - 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** +- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 - `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) - 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index 00cf30d1..f97cfd57 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -79,6 +79,7 @@ Flags: - 数据写入后按 `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` **行为要点**: 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 dcfc1a3d..7f07dfb9 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md @@ -159,6 +159,7 @@ Flags: **特性说明**: - CLI 把所有区域组装为**一次** `batch_update` 调用,由**服务端原子执行**:默认严格事务,任一项失败则**整批回滚**(不会出现部分区域已改、部分未改) - 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** +- 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 - `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) - 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index 120b18e0..ea8b07a9 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -81,6 +81,7 @@ Flags: - 数据写入后按 `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` **行为要点**: From 8f135ecde6de93e1bf6b0421aef52c565330017b Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sat, 8 Aug 2026 15:05:18 +0800 Subject: [PATCH 20/35] fix(sheet): reject unknown and mistyped fields in border edge configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseBorderStyles read only style and a string color, so every other key and any non-string color was silently dropped: {"top":{"style":"solid","colour":"#f00"}} succeeded and drew a border with no colour, and color: 123 did the same. That contradicts the unknown-key rejection this PR applies to --sheets and --styles — a partially applied style reported as success is harder to notice than an error. Each edge now accepts only style/color, rejects near-miss spellings with the canonical key, and fails when style or color is present with the wrong type or empty. All three entry points (set-style --border-styles-json, batch-set-style --ranges/--batch, and create --styles border_styles) share parseBorderStyles, so one fix covers them; tests assert the rejection happens before any MCP call on every path. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../helpers/sheet_create_with_data_test.go | 12 +++ internal/helpers/sheet_style.go | 29 ++++- internal/helpers/sheet_style_test.go | 101 ++++++++++++++++++ .../products/sheet/sheet-style-format.md | 2 +- .../references/sheet/sheet-style-format.md | 2 +- 6 files changed, 142 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60622429..93238e09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。已知限制:该分支实际读 `get_range_as_csv`,但 `sheet export` 叶子仍声明 `interface_ref: submit_export_job`,通过 Schema 查询 csv 能力会得到不准确的后端接口(仅影响审计元数据,不影响执行)。 - **`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`(四边边框)。 +- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create --styles` 三条路径同源校验)。 - **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。 ### Changed diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index 1af86612..d1dcfdb4 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -335,6 +335,18 @@ func TestSheetCreateValidatesBeforeCreatingDocument(t *testing.T) { 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", diff --git a/internal/helpers/sheet_style.go b/internal/helpers/sheet_style.go index 5fa5b1d2..b09cd436 100644 --- a/internal/helpers/sheet_style.go +++ b/internal/helpers/sheet_style.go @@ -143,6 +143,9 @@ var borderStyleEnum = map[string]bool{ 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 == "" { @@ -160,7 +163,20 @@ func parseBorderStyles(jsonStr string) (map[string]any, error) { if !borderEdgeEnum[edge] { return nil, fmt.Errorf("--border-styles-json 非法边名: %s(合法: top/bottom/left/right)", edge) } - style, _ := spec["style"].(string) + // 每条边只认 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) } @@ -168,7 +184,14 @@ func parseBorderStyles(jsonStr string) (map[string]any, error) { 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 color, ok := spec["color"].(string); ok && color != "" { + 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 @@ -457,7 +480,7 @@ func bindStyleFlags(cmd *cobra.Command) { 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 内)`) + 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 路径)。 diff --git a/internal/helpers/sheet_style_test.go b/internal/helpers/sheet_style_test.go index c70a1d4b..1f28de48 100644 --- a/internal/helpers/sheet_style_test.go +++ b/internal/helpers/sheet_style_test.go @@ -110,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 --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/skills/mono/references/products/sheet/sheet-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md index 7f07dfb9..20cb2b32 100644 --- a/skills/mono/references/products/sheet/sheet-style-format.md +++ b/skills/mono/references/products/sheet/sheet-style-format.md @@ -95,7 +95,7 @@ Flags: **特性说明**: - 每个可逐格样式维度(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 字段);只传的边生效、未传的边保留原状(按边合并)。**逐格应用**:对整个 range 每个单元格都画指定的边 = 网格效果(非仅最外框) +- `--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` 等 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 7f07dfb9..20cb2b32 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md @@ -95,7 +95,7 @@ Flags: **特性说明**: - 每个可逐格样式维度(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 字段);只传的边生效、未传的边保留原状(按边合并)。**逐格应用**:对整个 range 每个单元格都画指定的边 = 网格效果(非仅最外框) +- `--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` 等 From f06a24ec2eba9fce54496b687831aa6f622ff0db Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sat, 8 Aug 2026 18:26:40 +0800 Subject: [PATCH 21/35] fix(sheet): write export CSV atomically and reject cross-format flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSV 落盘从 os.WriteFile 改为仓库已有的 AtomicWrite:os.WriteFile 会先把 已存在的目标文件截断,写入中途失败(磁盘满、配额、I/O 错误)就把用户的原 文件毁掉了。改为写同目录临时文件再 rename,失败时原文件保持不变、临时文件 被清理。父目录仍先 stat 一次,保持与 xlsx 分支一致的「父目录不存在即报错」 语义,不让 AtomicWrite 的 MkdirAll 把拼错的路径悄悄建成目录。 格式分派后拒绝不属于当前分支的显式参数:--sheet-id / --range / --value-render-option / --allow-truncated 只有 csv 分支消费,此前落到默认的 xlsx 分支会被静默忽略。自动化漏写 --export-format csv 时,用户要的 --range 被丢掉、导出的却是整篇工作簿,命令仍报成功。现在按 Flags().Changed 检测并 在提交导出任务之前报错,同时列出所有误用的参数。反向不需要检查:xlsx 分支 没有专属参数,node / output / export-format 两条分支共用。 测试覆盖 rename 失败后原文件完好且目录无临时文件残留、四个 csv 专属 flag 在 默认与显式 xlsx 下各自被拒且零远程调用、多个参数同时误用时全部列出、csv 分支 照常接受它们;另加一条登记表与实际绑定 flag 的一致性测试,防止新增 flag 漏登记 (漏登记会重新引入静默忽略,误登记共享 flag 会拒掉合法的 xlsx 调用)。 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- internal/helpers/sheet_export.go | 33 ++++- internal/helpers/sheet_export_csv_test.go | 124 ++++++++++++++++++ .../references/products/sheet/sheet-export.md | 4 + .../references/sheet/sheet-export.md | 4 + 5 files changed, 164 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93238e09..7a1f2b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Added - **`sheet create` 支持建表即写入数据与样式** — 新增 `--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 export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。已知限制:该分支实际读 `get_range_as_csv`,但 `sheet export` 叶子仍声明 `interface_ref: submit_export_job`,通过 Schema 查询 csv 能力会得到不准确的后端接口(仅影响审计元数据,不影响执行)。 +- **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。csv 专属参数(`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated`)传给 xlsx 分支时直接报错,不再静默忽略——漏写 `--export-format csv` 时用户要的 `--range` 会被丢掉而导出整篇工作簿。已知限制:该分支实际读 `get_range_as_csv`,但 `sheet export` 叶子仍声明 `interface_ref: submit_export_job`,通过 Schema 查询 csv 能力会得到不准确的后端接口(仅影响审计元数据,不影响执行)。 - **`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 --styles` 三条路径同源校验)。 diff --git a/internal/helpers/sheet_export.go b/internal/helpers/sheet_export.go index 61ca677d..4cfb96d5 100644 --- a/internal/helpers/sheet_export.go +++ b/internal/helpers/sheet_export.go @@ -17,7 +17,13 @@ func runSheetExport(cmd *cobra.Command, _ []string) error { format, _ := cmd.Flags().GetString("export-format") switch format { case "", "xlsx": - // 继续走下方 xlsx 异步流程 + // xlsx 异步流程不消费 csv 专属选择器,静默忽略它们最危险的场景是自动化漏写 + // --export-format csv:用户要的 --range 被丢掉,导出的却是整篇工作簿,而命令 + // 仍报成功。显式传了就直接拒。 + if used := changedCsvOnlyExportFlags(cmd); len(used) > 0 { + return fmt.Errorf("%s 仅在 --export-format csv 下生效,xlsx 导出会忽略它们(会导出整篇工作簿);"+ + "请补上 --export-format csv,或去掉这些参数", strings.Join(used, " / ")) + } case "csv": return runSheetExportCsv(cmd) default: @@ -325,6 +331,21 @@ var valueRenderOptionEnum = map[string]bool{ "formatted_value": true, "raw_value": true, "formula": true, } +// csvOnlyExportFlags 是只有 --export-format csv 分支会读取的 flag。 +// 新增 csv 专属 flag 必须同步登记(TestCsvOnlyExportFlagsMatchBoundFlags 会盯住漂移)。 +var csvOnlyExportFlags = []string{"sheet-id", "range", "value-render-option", "allow-truncated"} + +// changedCsvOnlyExportFlags 返回用户显式设置过的 csv 专属 flag(带 -- 前缀,顺序稳定)。 +func changedCsvOnlyExportFlags(cmd *cobra.Command) []string { + var out []string + for _, name := range csvOnlyExportFlags { + if cmd.Flags().Changed(name) { + out = append(out, "--"+name) + } + } + return out +} + // runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。 func runSheetExportCsv(cmd *cobra.Command) error { nodeID := mustGetFlag(cmd, "node") @@ -401,7 +422,15 @@ func runSheetExportCsv(cmd *cobra.Command) error { if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() { outputPath = filepath.Join(outputPath, "sheet-export.csv") } - if err := os.WriteFile(outputPath, []byte(csvContent), 0o644); err != nil { + // 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、 + // 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename, + // 失败时原文件保持不变。 + // AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 xlsx 分支一致的「父目录不存在 + // 即报错」语义,避免把拼错的路径悄悄建成目录。 + 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 { diff --git a/internal/helpers/sheet_export_csv_test.go b/internal/helpers/sheet_export_csv_test.go index 5932fd5a..235ffc48 100644 --- a/internal/helpers/sheet_export_csv_test.go +++ b/internal/helpers/sheet_export_csv_test.go @@ -6,10 +6,13 @@ package helpers import ( "bytes" + "errors" "os" "path/filepath" "strings" "testing" + + "github.com/spf13/pflag" ) func TestSheetExportRejectsUnknownExportFormat(t *testing.T) { @@ -23,6 +26,84 @@ func TestSheetExportRejectsUnknownExportFormat(t *testing.T) { } } +// csv 专属选择器落到 xlsx 分支必须报错。静默忽略最危险的场景是自动化漏写 +// --export-format csv:用户要的是 --range 的一小块,实际拿到整篇工作簿, +// 命令却报成功。 +func TestSheetExportXlsxRejectsCsvOnlyFlags(t *testing.T) { + for _, tc := range []struct{ flag, value string }{ + {"sheet-id", "SHEET_1"}, + {"range", "A1:Z1000"}, + {"value-render-option", "raw_value"}, + {"allow-truncated", "true"}, + } { + // 默认(未传 --export-format)与显式 xlsx 都要拦。 + for _, format := range []string{"", "xlsx"} { + args := []string{"node", "NODE", tc.flag, tc.value} + if format != "" { + args = append(args, "export-format", format) + } + caller := &scriptedToolCaller{} + err := executeSheetExportCoverage(t, caller, args...) + if err == nil { + t.Fatalf("--%s + export-format=%q 未报错,csv 专属参数被静默忽略", tc.flag, format) + } + for _, want := range []string{"--" + tc.flag, "--export-format csv"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("--%s 的错误信息缺少 %q: %v", tc.flag, want, err) + } + } + if caller.calls != 0 { + t.Fatalf("--%s: calls = %d, want 0(必须在提交导出任务之前失败)", tc.flag, caller.calls) + } + } + } + + // 多个一起传时全部列出,避免用户改一个再撞一次。 + err := executeSheetExportCoverage(t, &scriptedToolCaller{}, + "node", "NODE", "sheet-id", "SHEET_1", "range", "A1:B2") + if err == nil || !strings.Contains(err.Error(), "--sheet-id / --range") { + t.Fatalf("err = %v, want 同时列出两个 flag", err) + } + + // csv 分支照常接受这些参数。 + ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} + if err := executeSheetExportCoverage(t, ok, "node", "NODE", "export-format", "csv", + "sheet-id", "SHEET_1", "range", "A1:B2", "value-render-option", "raw_value", + "allow-truncated", "true"); err != nil { + t.Fatalf("csv 分支应接受这些参数: %v", err) + } +} + +// csvOnlyExportFlags 是手工维护的:漏登记会让一个 csv 专属 flag 在 xlsx 分支被 +// 静默忽略;误登记共享 flag 会把合法的 xlsx 调用拒掉。 +func TestCsvOnlyExportFlagsMatchBoundFlags(t *testing.T) { + bound := map[string]bool{} + newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true }) + + // xlsx 与 csv 两条分支都消费的 flag,不属于 csv 专属。 + shared := map[string]bool{"node": true, "output": true, "export-format": true} + + listed := map[string]bool{} + for _, name := range csvOnlyExportFlags { + if listed[name] { + t.Fatalf("csvOnlyExportFlags 重复登记 %q", name) + } + listed[name] = true + if !bound[name] { + t.Errorf("csvOnlyExportFlags 登记了 %q,但 export 命令没有绑定它", name) + } + if shared[name] { + t.Errorf("csvOnlyExportFlags 登记了共享 flag %q,会拒掉合法的 xlsx 调用", name) + } + } + for name := range bound { + if !listed[name] && !shared[name] { + t.Errorf("export 命令绑定了 %q,但既未登记为 csv 专属也不在共享集合里"+ + "(新增 flag 需明确它在哪条分支生效)", name) + } + } +} + func TestSheetExportCsvRequiresNode(t *testing.T) { caller := &scriptedToolCaller{} err := executeSheetExportCoverage(t, caller, "export-format", "csv") @@ -170,6 +251,49 @@ func TestSheetExportCsvWritesIntoDirectory(t *testing.T) { } } +// 落盘失败必须保住已有文件: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 := executeSheetExportCoverage(t, caller, + "node", "NODE", "export-format", "csv", "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") diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index 828d58e4..4a11dab6 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -52,6 +52,10 @@ Flags: **`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` / `--allow-truncated` 生效。不传 `--output` 时 CSV 正文打印到 stdout。 +**csv 专属参数漏写 `--export-format csv` 会直接报错**:`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated` 只在 csv 分支生效,传给 xlsx 分支时命令报错而不是静默忽略——否则本想按 `--range` 导一小块,实际会拿到整篇工作簿且报成功。 + +**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。 + **超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: - 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) - 改用默认的 `--export-format xlsx` 导出完整表格 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md index 6e065c87..1dfbdb37 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -52,6 +52,10 @@ Flags: **`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` / `--allow-truncated` 生效。不传 `--output` 时 CSV 正文打印到 stdout。 +**csv 专属参数漏写 `--export-format csv` 会直接报错**:`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated` 只在 csv 分支生效,传给 xlsx 分支时命令报错而不是静默忽略——否则本想按 `--range` 导一小块,实际会拿到整篇工作簿且报成功。 + +**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。 + **超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: - 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) - 改用默认的 `--export-format xlsx` 导出完整表格 From ac0125e468eb7eed022a91499db50c05e25093bd Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sun, 9 Aug 2026 15:01:14 +0800 Subject: [PATCH 22/35] docs(changelog): record the stricter --length parsing as a behaviour change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --length 的解析由 fmt.Sscanf("%d") 改为 strconv.Atoi,影响 sheet insert-dimension / delete-dimension / update-dimension 三个既有命令。这是对既有 命令的用户可见行为变更,此前只作为实现细节修掉,CHANGELOG 与 PR 描述都没记:原先 Sscanf 只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作(删除方向不可 回滚);现在整个值必须是合法正整数。原先依赖宽松解析、在传畸形 --length 的脚本 升级后会开始报错,用户需要能在发布说明里找到解释。 CHANGELOG 的 Unreleased ### Changed 补一条(含升级影响与 add-dimension 不受影响 的说明),PR 描述的 Summary 补一张 Behaviour change 表。 同时补一条回归测试钉住该行为:三个命令 × 六种畸形值(2x / 3foo / "1 2" / 0x10 / abc / 空串)都必须报错且错误信息指明 --length。文档写了的行为需要有测试守着,否则 改回宽松解析不会被发现。 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../sheet_dimension_coverage_more_test.go | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1f2b76..28f21131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`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 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 严格校验,不受影响。 ## [1.0.58-beta.1] - 2026-08-07 diff --git a/internal/helpers/sheet_dimension_coverage_more_test.go b/internal/helpers/sheet_dimension_coverage_more_test.go index 1cd0529a..ac366b28 100644 --- a/internal/helpers/sheet_dimension_coverage_more_test.go +++ b/internal/helpers/sheet_dimension_coverage_more_test.go @@ -75,6 +75,33 @@ 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}) From 10bb2ec205ead21753b73483a0944fd061dbb100 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sun, 9 Aug 2026 15:30:32 +0800 Subject: [PATCH 23/35] docs(changelog): record the composite-orchestration contract gap on both leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sheet create 带 --values/--sheets/--styles 时会依次执行探活、重命名、写入、回读与 可选样式操作,已不是一次 create_workspace_sheet 直接调用;sheet export --export-format csv 实际读 get_range_as_csv、完全不碰 submit_export_job。两个叶子 却都仍声明 interface_mode: mcp 加单一 interface_ref,Schema 消费者会误判为单次 RPC,并把编排步骤或另一分支的参数当成该 RPC 的入参。 此前只有 csv 分支记了一条含糊的已知限制、create 侧完全没记。现在两条都写清楚: 不准确的具体表现、影响面(仅审计元数据,不影响执行),以及诚实的声明方式——拆成 独立叶子,或把叶子改为 interface_mode: composite。同时记下后者为何留待后续迁移: 对既有叶子而言 interface_mode 变更在 schema-compatibility 中是无条件失败, checkToolCompatibility 对该字段没有任何豁免通道,而 interface_ref 的 reviewed redirect 豁免明确要求 mode 保持 mcp 不变。 注意:这是把契约不准确记录成已知限制,不是修复。评审要求的是结构性修改。 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f21131..f15549a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Added -- **`sheet create` 支持建表即写入数据与样式** — 新增 `--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 export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。csv 专属参数(`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated`)传给 xlsx 分支时直接报错,不再静默忽略——漏写 `--export-format csv` 时用户要的 `--range` 会被丢掉而导出整篇工作簿。已知限制:该分支实际读 `get_range_as_csv`,但 `sheet export` 叶子仍声明 `interface_ref: submit_export_job`,通过 Schema 查询 csv 能力会得到不准确的后端接口(仅影响审计元数据,不影响执行)。 +- **`sheet create` 支持建表即写入数据与样式** — 新增 `--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。已知限制:带上这三个 flag 时该叶子不再是一次 `create_workspace_sheet` 直接调用,而会依次执行探活、重命名、写入、回读与可选样式操作,但仍声明 `interface_mode: mcp` + `interface_ref: create_workspace_sheet`,通过 Schema 审计会误判为单次 RPC、并把编排步骤的参数当成该 RPC 的入参(仅影响审计元数据,不影响执行)。诚实的声明应是拆成独立的 composite 叶子(如 `sheet +create-with-data`)或把本叶子改为 `interface_mode: composite`;后者对既有叶子是无条件不兼容的契约变更(`interface_mode` 变更在 schema-compatibility 中没有豁免通道),因此留待后续单独迁移。 +- **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。csv 专属参数(`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated`)传给 xlsx 分支时直接报错,不再静默忽略——漏写 `--export-format csv` 时用户要的 `--range` 会被丢掉而导出整篇工作簿。已知限制:该分支实际读 `get_range_as_csv`、完全不会调用 `submit_export_job`,但 `sheet export` 叶子仍声明 `interface_mode: mcp` + `interface_ref: submit_export_job`,于是一个叶子横跨两条后端不同的分支却只发布一个接口引用,Agent 在 csv 场景下的接口审计、参数映射与后端能力判断都会拿到错误信息(仅影响审计元数据,不影响执行)。诚实的声明应是把 csv 拆成独立叶子(单次 RPC,`interface_ref: get_range_as_csv`)或把本叶子改为 `interface_mode: composite`;与 `sheet create` 同理,后者对既有叶子无豁免通道,因此留待后续单独迁移。 - **`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 --styles` 三条路径同源校验)。 From 332b74e8ce16f4640634cddf2fa2e0b8184dc6a8 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sun, 9 Aug 2026 17:47:50 +0800 Subject: [PATCH 24/35] refactor(sheet): split create-with-data and export-csv onto their own leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both capabilities were added as flags on an existing leaf, and in both cases the leaf's published interface stopped describing what the command did: - `sheet create --values/--sheets/--styles` orchestrates create → probe → resolve default worksheet → write → read back → optional styles, yet the leaf still published `interface_mode: mcp` + `create_workspace_sheet`. - `sheet export --export-format csv` reads `get_range_as_csv` and never invokes `submit_export_job`, yet the leaf published `submit_export_job`. Each moves to its own command, declaring the interface it actually uses: `sheet create-with-data` is `composite` with a reviewed reason and no `interface_ref`; `sheet export-csv` is `mcp` + `get_range_as_csv`. Both are pinned in the interface-disposition contract test. `sheet create` and `sheet export` are restored byte-for-byte to main, so the compatibility gates see two `command_added` additions instead of four locked-field changes. The split also removes a user-visible trap: `--range` without `--export-format csv` used to be silently discarded and the whole workbook exported; the cross-format flags no longer exist, pinned by TestSheetExportAndExportCsvFlagsDoNotLeak. Drops the 8 now-stale mapping-ledger exclusions that covered the flags on `sheet.create_workspace_sheet` / `sheet.submit_export_job`, shrinking this branch's exclusion surface. Skill references and CHANGELOG follow the split. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 +- ...ema_interface_disposition_contract_test.go | 5 + .../cli/schema_parameter_mapping_ledger.go | 8 - internal/helpers/sheet.go | 20 +- internal/helpers/sheet_create_with_data.go | 138 +++++++++- .../helpers/sheet_create_with_data_test.go | 58 +++-- internal/helpers/sheet_export.go | 191 +------------- internal/helpers/sheet_export_csv.go | 241 ++++++++++++++++++ internal/helpers/sheet_export_csv_test.go | 171 +++++-------- internal/helpers/sheet_style_test.go | 2 +- internal/helpers/sheet_workbook.go | 72 +----- .../references/products/sheet/sheet-export.md | 84 +++--- .../products/sheet/sheet-workbook.md | 30 ++- .../references/sheet/sheet-export.md | 84 +++--- .../references/sheet/sheet-workbook.md | 30 ++- 15 files changed, 643 insertions(+), 497 deletions(-) create mode 100644 internal/helpers/sheet_export_csv.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f15549a3..73c51a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Added -- **`sheet create` 支持建表即写入数据与样式** — 新增 `--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。已知限制:带上这三个 flag 时该叶子不再是一次 `create_workspace_sheet` 直接调用,而会依次执行探活、重命名、写入、回读与可选样式操作,但仍声明 `interface_mode: mcp` + `interface_ref: create_workspace_sheet`,通过 Schema 审计会误判为单次 RPC、并把编排步骤的参数当成该 RPC 的入参(仅影响审计元数据,不影响执行)。诚实的声明应是拆成独立的 composite 叶子(如 `sheet +create-with-data`)或把本叶子改为 `interface_mode: composite`;后者对既有叶子是无条件不兼容的契约变更(`interface_mode` 变更在 schema-compatibility 中没有豁免通道),因此留待后续单独迁移。 -- **`sheet export --export-format csv`** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样),不传则打印到 stdout。数据超出单次读取上限时默认报错且不写文件,需 `--allow-truncated` 显式接受不完整结果。csv 专属参数(`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated`)传给 xlsx 分支时直接报错,不再静默忽略——漏写 `--export-format csv` 时用户要的 `--range` 会被丢掉而导出整篇工作簿。已知限制:该分支实际读 `get_range_as_csv`、完全不会调用 `submit_export_job`,但 `sheet export` 叶子仍声明 `interface_mode: mcp` + `interface_ref: submit_export_job`,于是一个叶子横跨两条后端不同的分支却只发布一个接口引用,Agent 在 csv 场景下的接口审计、参数映射与后端能力判断都会拿到错误信息(仅影响审计元数据,不影响执行)。诚实的声明应是把 csv 拆成独立叶子(单次 RPC,`interface_ref: get_range_as_csv`)或把本叶子改为 `interface_mode: composite`;与 `sheet create` 同理,后者对既有叶子无豁免通道,因此留待后续单独迁移。 +- **`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 --styles` 三条路径同源校验)。 +- **`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 diff --git a/internal/app/schema_interface_disposition_contract_test.go b/internal/app/schema_interface_disposition_contract_test.go index 3599a35f..cf5ffa90 100644 --- a/internal/app/schema_interface_disposition_contract_test.go +++ b/internal/app/schema_interface_disposition_contract_test.go @@ -36,6 +36,11 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) { mode: "composite", 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", mode: "composite", diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index e4ce345f..c94b2c4c 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -555,9 +555,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.create_sheet_comment --node": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_sheet_comment --range": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_sheet_comment --sheet-id": "Reviewed unpinned adapter: sheet.create_sheet_comment has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", - "sheet.create_workspace_sheet --sheets": "Wrapper orchestration input: the CLI feeds these typed tables to sheet/table_put after the workbook exists; create_workspace_sheet has no such property.", - "sheet.create_workspace_sheet --styles": "Wrapper orchestration input: the CLI expands this config into set_cell_range / update_dimension / merge_cells calls after the data write; create_workspace_sheet has no such property.", - "sheet.create_workspace_sheet --values": "Wrapper orchestration input: the CLI encodes this matrix as CSV for sheet/set_range_from_csv after the workbook exists; create_workspace_sheet has no such property.", "sheet.delete_cond_format --yes": "local confirmation control", "sheet.delete_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.delete_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.delete_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", @@ -621,12 +618,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.reply_sheet_comment --mention": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --mention is a CLI wrapper input and does not publish a direct interface property.", "sheet.reply_sheet_comment --node": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.", "sheet.show_gridline --sheet-id": "Reviewed unpinned adapter: sheet.show_gridline has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", - "sheet.submit_export_job --allow-truncated": "local truncation policy: gates whether an incomplete csv read may still be written; not a property of any RPC.", - "sheet.submit_export_job --export-format": "Wrapper router input: --export-format selects between the xlsx submit_export_job path and the synchronous csv get_range_as_csv path; it is not a property of the declared submit_export_job interface.", "sheet.submit_export_job --output": "local destination consumed after export job completion", - "sheet.submit_export_job --range": "Wrapper branch input: only the --export-format csv path consumes it, as the range argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", - "sheet.submit_export_job --sheet-id": "Wrapper branch input: only the --export-format csv path consumes it, as the sheetId argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", - "sheet.submit_export_job --value-render-option": "Wrapper branch input: only the --export-format csv path consumes it, as the valueRenderOption argument of sheet/get_range_as_csv; the declared submit_export_job interface has no such property.", "sheet.table_get --no-header": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --no-header is a CLI wrapper input and does not publish a direct interface property.", "sheet.table_get --range": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.table_get --sheet-id": "Reviewed unpinned adapter: sheet.table_get has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index d5a02948..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 搜索表格模板 @@ -141,22 +143,16 @@ func newSheetCommand() *cobra.Command { CLIPath: "sheet export", PrimaryCLIPath: "sheet export", }, - Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", + Description: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载)。", DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, - // interface_ref 声明 xlsx 主路径的 submit_export_job,与 main 一致。 - // 已知取舍:--export-format csv 是互斥分支,直接读 get_range_as_csv, - // 该分支下 submit_export_job 不执行,因此本声明未覆盖 csv 分支的后端接口。 - // 保持 mcp 是为了不改动 main 已有的声明(interface_ref 为审计元数据、 - // 运行时不消费,实测改成任意值仍按 CLI 源码正确路由),csv 分支的接口 - // 归属另行处理,不在本次改动范围内。 Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "submit_export_job"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载),或用 --export-format csv 同步导出单个工作表。", - UseWhen: []string{"需要把在线电子表格导出为 Excel 文件、CSV 或拿到 downloadUrl 时"}, + AgentSummary: "一站式导出 axls 为 xlsx(内部提交+轮询,可选下载)。", + UseWhen: []string{"需要把在线电子表格导出为 Excel 文件或拿到 downloadUrl 时"}, AvoidWhen: []string{"禁止用 range read 拼 xlsx;本地已有 xlsx 节点用 doc download;Agent 不要外层再轮询"}, Examples: []string{"dws sheet export --node --output ./report.xlsx"}, }, @@ -165,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() @@ -244,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_create_with_data.go b/internal/helpers/sheet_create_with_data.go index 334f237e..4881df17 100644 --- a/internal/helpers/sheet_create_with_data.go +++ b/internal/helpers/sheet_create_with_data.go @@ -16,17 +16,143 @@ import ( "time" "unicode/utf8" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) // ============================================================================ -// sheet create --values / --sheets / --styles 编排 +// sheet create-with-data:创建表格并写入初始数据(可选样式)的编排 // -// 创建带初始数据(可选样式)的表格是多步流程,没有单一 MCP 工具承载: +// 这是多步流程,没有单一 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 @@ -369,7 +495,7 @@ func sortedMapKeys(m map[string]any) []string { // validateCreateSheetSpec 按 table_put 的输入契约校验单个 sheet spec 的每个已提供 // 字段。纯函数、不发请求:调用方在创建文档之前调用,非法输入不会产生任何 MCP 调用。 func validateCreateSheetSpec(spec map[string]any) error { - // sheetId 在 table_put 契约里合法(且优先级高于 name),但对 sheet create + // sheetId 在 table_put 契约里合法(且优先级高于 name),但对 create-with-data // 没有意义:文档此刻还不存在,任何 sheetId 都不可能是它的工作表。传了只会 // 让写入落到别处或直接失败,而重命名默认表、回读校验都是按 name 定位的。 for _, key := range sortedMapKeys(spec) { @@ -544,7 +670,7 @@ func isTableScalar(v any) bool { } // specWritesHeader 判断该 spec 是否会写入表头行:显式 header 优先;缺省时写表头 -// (overwrite 默认写;append 落在空表上也写,而 sheet create 的工作表都是新建空表)。 +// (overwrite 默认写;append 落在空表上也写,而本命令的工作表都是新建空表)。 func specWritesHeader(spec map[string]any) bool { if v, ok := spec["header"].(bool); ok { return v @@ -591,7 +717,7 @@ const ( maxCSVWriteChars = 2 * 1000 * 1000 ) -// ── create --styles:对齐飞书 +workbook-create --styles 协议 ────────────────── +// ── create-with-data --styles:对齐飞书 +workbook-create --styles 协议 ──────── // // {"styles":[{"name":"子表名", // @@ -1257,7 +1383,7 @@ func firstNonEmptySheetSpecCell(spec map[string]any) (string, bool) { col0, row0 = c, r } } - // mode=append 时服务端按「已有数据的下一行」定位:sheet create 新建的工作表 + // mode=append 时服务端按「已有数据的下一行」定位:本命令新建的工作表 // 都是空表,追加就从第 1 行开始,startCell 的行号被忽略(列号仍生效)。 if mode, _ := spec["mode"].(string); mode == "append" { row0 = 1 diff --git a/internal/helpers/sheet_create_with_data_test.go b/internal/helpers/sheet_create_with_data_test.go index d1dcfdb4..39e1a4aa 100644 --- a/internal/helpers/sheet_create_with_data_test.go +++ b/internal/helpers/sheet_create_with_data_test.go @@ -24,25 +24,13 @@ func installSheetProductArgs(t *testing.T) { testseam.Swap(t, &os.Args, []string{"dws", "sheet"}) } -// newCreateCmdForTest 取出 sheet create 命令本体,便于直接驱动 RunE。 -func newCreateCmdForTest(t *testing.T) *cobra.Command { - t.Helper() - for _, cmd := range newWorkbookCmds() { - if cmd.Name() == "create" { - return cmd - } - } - t.Fatal("create command not found in newWorkbookCmds()") - return nil -} - -// runCreate 用脚本化的 MCP 响应驱动一次 sheet create。 +// 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 := newCreateCmdForTest(t) + cmd := newSheetCreateWithDataCmd() for name, value := range flags { if err := cmd.Flags().Set(name, value); err != nil { t.Fatalf("set --%s: %v", name, err) @@ -63,13 +51,36 @@ func createOKSteps(csvReadback string) []scriptedToolStep { } } -func TestSheetCreateEmptyKeepsSingleCallBehaviour(t *testing.T) { +// sheet create 必须保持「一次 create_workspace_sheet」不变(它的叶子契约就是单次 +// RPC);带初始数据的编排在 sheet create-with-data 上,两条命令的职责不能混。 +func TestSheetCreateStaysSingleCallAndHasNoDataFlags(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"nodeId":"N"}`}}} - if err := runCreate(t, caller, map[string]string{"name": "空表"}); err != nil { - t.Fatalf("create without data: %v", err) + 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 (不带初始数据时只调 create_workspace_sheet)", caller.calls) + 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) @@ -82,10 +93,15 @@ func TestSheetCreateRejectsConflictingAndOrphanFlags(t *testing.T) { 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 搭配", + want: "--values 与 --sheets 必须给一个", }, { name: "values-and-sheets", @@ -611,7 +627,7 @@ func TestResolveSheetIDsByNameFailures(t *testing.T) { } } -// create --sheets 时 table_put 后取工作表列表失败,须带 nodeId 报错而非静默成功。 +// create-with-data --sheets 时 table_put 后取工作表列表失败,须带 nodeId 报错而非静默成功。 func TestSheetCreateWithSheetsReadbackListFailureSurfacesNodeID(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{ {text: `{"nodeId":"NODE_1"}`}, @@ -1597,7 +1613,7 @@ func runCreateRecording(t *testing.T, inner *scriptedToolCaller, flags map[strin deps.Out.errW = io.Discard installImmediateTiming(t) installSheetProductArgs(t) - cmd := newCreateCmdForTest(t) + cmd := newSheetCreateWithDataCmd() for name, value := range flags { if err := cmd.Flags().Set(name, value); err != nil { t.Fatalf("set --%s: %v", name, err) diff --git a/internal/helpers/sheet_export.go b/internal/helpers/sheet_export.go index 4cfb96d5..98b09c33 100644 --- a/internal/helpers/sheet_export.go +++ b/internal/helpers/sheet_export.go @@ -14,22 +14,6 @@ import ( ) func runSheetExport(cmd *cobra.Command, _ []string) error { - format, _ := cmd.Flags().GetString("export-format") - switch format { - case "", "xlsx": - // xlsx 异步流程不消费 csv 专属选择器,静默忽略它们最危险的场景是自动化漏写 - // --export-format csv:用户要的 --range 被丢掉,导出的却是整篇工作簿,而命令 - // 仍报成功。显式传了就直接拒。 - if used := changedCsvOnlyExportFlags(cmd); len(used) > 0 { - return fmt.Errorf("%s 仅在 --export-format csv 下生效,xlsx 导出会忽略它们(会导出整篇工作簿);"+ - "请补上 --export-format csv,或去掉这些参数", strings.Join(used, " / ")) - } - case "csv": - return runSheetExportCsv(cmd) - default: - return fmt.Errorf("--export-format 仅支持 xlsx 或 csv,当前值: %s", format) - } - nodeID := mustGetFlag(cmd, "node") if nodeID == "" { return fmt.Errorf("flag --node is required") @@ -262,16 +246,8 @@ func inferSheetExportFilename(rawURL string) string { func newExportCmd() *cobra.Command { exportCmd := &cobra.Command{ Use: "export", - Short: "导出表格为 xlsx(异步一站式)或 CSV(单表同步)", - Long: `将钉钉在线电子表格导出为 Office xlsx 或 CSV。 - -格式(--export-format): - xlsx(默认) 整篇表格导出为 xlsx,异步任务一站式(提交+轮询+下载,见下方流程) - csv 导出单个工作表为纯 RFC4180 CSV(同步)。用 --sheet-id 指定工作表、 - --range 限定范围、--value-render-option 选择取值模式;--output 落盘, - 不传则打印到 stdout。超大表会截断并给出警告,请用 --range 分块或改用 xlsx。 - -以下为 xlsx 异步流程: + Short: "导出表格为 xlsx(异步任务一站式)", + Long: `将钉钉在线电子表格导出为 Office xlsx 格式(单命令一站式)。 执行流程(全程自动,无需 Agent 介入轮询): 1. 提交导出任务(submit_export_job),获取 jobId @@ -304,171 +280,10 @@ func newExportCmd() *cobra.Command { dws sheet export --node NODE_ID --output ./report.xlsx # --output 为目录时,自动按下载链接里的文件名保存 - dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --output ./ - - # 导出单个工作表为 CSV 文件 - dws sheet export --node NODE_ID --export-format csv --sheet-id SHEET_ID --output ./data.csv - - # 导出 CSV 到 stdout(可管道处理) - dws sheet export --node NODE_ID --export-format csv --sheet-id SHEET_ID`, + dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --output ./`, RunE: runSheetExport, } exportCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") exportCmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录)") - // 注意:不要把这个 flag 叫 --format。根级已有 persistent 的 -f/--format(输出格式), - // 同名局部 flag 会在 cobra 的 flag 合并中把它整个挤掉,导致 -f 变成未知简写、 - // 且 --format 被这里吞掉(命名与 aitable export-data 的 --export-format 一致)。 - exportCmd.Flags().String("export-format", "xlsx", "导出格式: xlsx(默认,异步任务) / csv(单个工作表,同步)") - exportCmd.Flags().String("sheet-id", "", "工作表 ID 或名称(--export-format csv 时指定要导出的工作表,不传则第一个)") - exportCmd.Flags().String("range", "", "导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出)") - exportCmd.Flags().String("value-render-option", "", "取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula") - exportCmd.Flags().Bool("allow-truncated", false, "允许 CSV 被截断时仍然导出(仅 --export-format csv)。默认截断即报错并不写文件,避免不完整数据被当成完整导出") return exportCmd } - -// valueRenderOptionEnum 是 --value-render-option 的合法取值(仅 csv 路径生效)。 -var valueRenderOptionEnum = map[string]bool{ - "formatted_value": true, "raw_value": true, "formula": true, -} - -// csvOnlyExportFlags 是只有 --export-format csv 分支会读取的 flag。 -// 新增 csv 专属 flag 必须同步登记(TestCsvOnlyExportFlagsMatchBoundFlags 会盯住漂移)。 -var csvOnlyExportFlags = []string{"sheet-id", "range", "value-render-option", "allow-truncated"} - -// changedCsvOnlyExportFlags 返回用户显式设置过的 csv 专属 flag(带 -- 前缀,顺序稳定)。 -func changedCsvOnlyExportFlags(cmd *cobra.Command) []string { - var out []string - for _, name := range csvOnlyExportFlags { - if cmd.Flags().Changed(name) { - out = append(out, "--"+name) - } - } - return out -} - -// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。 -func runSheetExportCsv(cmd *cobra.Command) 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 ...)、改用 --export-format xlsx 导出完整表格,"+ - "或确认可接受不完整数据后加 --allow-truncated", - firstNonEmpty(outputPath, "stdout")) - } - if hasMore { - deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" + - "请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 --export-format 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 父目录,先探一次,保持与 xlsx 分支一致的「父目录不存在 - // 即报错」语义,避免把拼错的路径悄悄建成目录。 - 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.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 index 235ffc48..25bddc89 100644 --- a/internal/helpers/sheet_export_csv_test.go +++ b/internal/helpers/sheet_export_csv_test.go @@ -9,104 +9,56 @@ import ( "errors" "os" "path/filepath" + "reflect" + "sort" "strings" "testing" "github.com/spf13/pflag" ) -func TestSheetExportRejectsUnknownExportFormat(t *testing.T) { - caller := &scriptedToolCaller{} - err := executeSheetExportCoverage(t, caller, "node", "NODE", "export-format", "pdf") - if err == nil || !strings.Contains(err.Error(), "--export-format 仅支持 xlsx 或 csv") { - t.Fatalf("err = %v, want unsupported export format", err) - } - if caller.calls != 0 { - t.Fatalf("calls = %d, want 0", caller.calls) +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) } -// csv 专属选择器落到 xlsx 分支必须报错。静默忽略最危险的场景是自动化漏写 -// --export-format csv:用户要的是 --range 的一小块,实际拿到整篇工作簿, -// 命令却报成功。 -func TestSheetExportXlsxRejectsCsvOnlyFlags(t *testing.T) { - for _, tc := range []struct{ flag, value string }{ - {"sheet-id", "SHEET_1"}, - {"range", "A1:Z1000"}, - {"value-render-option", "raw_value"}, - {"allow-truncated", "true"}, - } { - // 默认(未传 --export-format)与显式 xlsx 都要拦。 - for _, format := range []string{"", "xlsx"} { - args := []string{"node", "NODE", tc.flag, tc.value} - if format != "" { - args = append(args, "export-format", format) - } - caller := &scriptedToolCaller{} - err := executeSheetExportCoverage(t, caller, args...) - if err == nil { - t.Fatalf("--%s + export-format=%q 未报错,csv 专属参数被静默忽略", tc.flag, format) - } - for _, want := range []string{"--" + tc.flag, "--export-format csv"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("--%s 的错误信息缺少 %q: %v", tc.flag, want, err) - } - } - if caller.calls != 0 { - t.Fatalf("--%s: calls = %d, want 0(必须在提交导出任务之前失败)", tc.flag, caller.calls) - } - } +// 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) } - // 多个一起传时全部列出,避免用户改一个再撞一次。 - err := executeSheetExportCoverage(t, &scriptedToolCaller{}, - "node", "NODE", "sheet-id", "SHEET_1", "range", "A1:B2") - if err == nil || !strings.Contains(err.Error(), "--sheet-id / --range") { - t.Fatalf("err = %v, want 同时列出两个 flag", err) - } - - // csv 分支照常接受这些参数。 - ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} - if err := executeSheetExportCoverage(t, ok, "node", "NODE", "export-format", "csv", - "sheet-id", "SHEET_1", "range", "A1:B2", "value-render-option", "raw_value", - "allow-truncated", "true"); err != nil { - t.Fatalf("csv 分支应接受这些参数: %v", err) - } -} - -// csvOnlyExportFlags 是手工维护的:漏登记会让一个 csv 专属 flag 在 xlsx 分支被 -// 静默忽略;误登记共享 flag 会把合法的 xlsx 调用拒掉。 -func TestCsvOnlyExportFlagsMatchBoundFlags(t *testing.T) { - bound := map[string]bool{} - newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true }) - - // xlsx 与 csv 两条分支都消费的 flag,不属于 csv 专属。 - shared := map[string]bool{"node": true, "output": true, "export-format": true} - - listed := map[string]bool{} - for _, name := range csvOnlyExportFlags { - if listed[name] { - t.Fatalf("csvOnlyExportFlags 重复登记 %q", name) - } - listed[name] = true - if !bound[name] { - t.Errorf("csvOnlyExportFlags 登记了 %q,但 export 命令没有绑定它", name) - } - if shared[name] { - t.Errorf("csvOnlyExportFlags 登记了共享 flag %q,会拒掉合法的 xlsx 调用", name) + 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) } } - for name := range bound { - if !listed[name] && !shared[name] { - t.Errorf("export 命令绑定了 %q,但既未登记为 csv 专属也不在共享集合里"+ - "(新增 flag 需明确它在哪条分支生效)", name) - } + // 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。 + if csvFlags["export-format"] { + t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)") } } func TestSheetExportCsvRequiresNode(t *testing.T) { caller := &scriptedToolCaller{} - err := executeSheetExportCoverage(t, caller, "export-format", "csv") + err := executeSheetExportCsv(t, caller) if err == nil || !strings.Contains(err.Error(), "--node is required") { t.Fatalf("err = %v, want required node", err) } @@ -118,8 +70,7 @@ func TestSheetExportCsvRequiresNode(t *testing.T) { // --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。 func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) { caller := &scriptedToolCaller{} - err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "value-render-option", "pretty") + 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) @@ -130,8 +81,7 @@ func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) { for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} { ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} - if err := executeSheetExportCoverage(t, ok, - "node", "NODE", "export-format", "csv", "value-render-option", option); err != nil { + if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil { t.Fatalf("value-render-option=%q 应被接受: %v", option, err) } } @@ -139,9 +89,8 @@ func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) { func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) { caller := &scriptedToolCaller{dry: true} - if err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", - "sheet-id", "SHEET_1", "output", "out.csv"); err != nil { + 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 { @@ -149,18 +98,24 @@ func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) { } } -func TestSheetExportCsvForwardsRangeAndWarnsOnTruncation(t *testing.T) { +func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{ {text: `{"csv":"a,b\n","hasMore":true}`}, }} // 截断默认失败,所以要显式放行才能走到落盘/输出。 - if err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "range", "A1:Z1000", - "allow-truncated", "true"); err != nil { + 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) } - if got := caller.args["range"]; got != "A1:Z1000" { - t.Fatalf("range 未透传: %#v", caller.args["range"]) + 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 { @@ -179,8 +134,7 @@ func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{ {text: `{"csv":"a,b\n","hasMore":true}`}, }} - err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "output", out) + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out) if err == nil { t.Fatal("截断时未放行应报错,而非静默成功") } @@ -206,9 +160,8 @@ func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) caller := &scriptedToolCaller{steps: []scriptedToolStep{ {text: `{"csv":"a,b\n","hasMore":true}`}, }} - if err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "output", out, - "allow-truncated", "true"); err != nil { + 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) @@ -225,8 +178,7 @@ 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 := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "output", out); err != nil { + if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil { t.Fatalf("完整读取不应要求放行: %v", err) } body, err := os.ReadFile(out) @@ -238,8 +190,7 @@ func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) { func TestSheetExportCsvWritesIntoDirectory(t *testing.T) { dir := t.TempDir() caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} - if err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "output", dir); err != nil { + 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")) @@ -266,8 +217,7 @@ func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) { atomicRename = func(string, string) error { return errors.New("no space left on device") } caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}} - err := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "output", out) + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out) if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") { t.Fatalf("err = %v, want write failure", err) } @@ -297,8 +247,7 @@ func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) { 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 := executeSheetExportCoverage(t, caller, - "node", "NODE", "export-format", "csv", "output", missing) + err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing) if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") { t.Fatalf("err = %v, want write failure", err) } @@ -306,7 +255,7 @@ func TestSheetExportCsvReportsWriteFailure(t *testing.T) { func TestSheetExportCsvReportsReadFailure(t *testing.T) { caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}} - err := executeSheetExportCoverage(t, caller, "node", "NODE", "export-format", "csv") + err := executeSheetExportCsv(t, caller, "node", "NODE") if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") { t.Fatalf("err = %v, want read failure", err) } @@ -356,7 +305,7 @@ func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) { t.Fatal(err) } caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}} - err := executeSheetExportCoverage(t, caller, "node", "NODE", "export-format", "csv", "output", out) + 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) } @@ -381,14 +330,14 @@ func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) { var stdout, stderr bytes.Buffer deps.Out.w, deps.Out.errW = &stdout, &stderr - cmd := newExportCmd() + cmd := newSheetExportCsvCmd() // 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。 - for _, kv := range [][2]string{{"node", "NODE"}, {"export-format", "csv"}, {"allow-truncated", "true"}} { + 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 := runSheetExport(cmd, nil); err != nil { + if err := runSheetExportCsv(cmd, nil); err != nil { t.Fatalf("csv export: %v", err) } diff --git a/internal/helpers/sheet_style_test.go b/internal/helpers/sheet_style_test.go index 1f28de48..61b0734c 100644 --- a/internal/helpers/sheet_style_test.go +++ b/internal/helpers/sheet_style_test.go @@ -113,7 +113,7 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) { // 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默 // 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键 -// 是同一条不变量,三条使用路径(单区域 flag、batch、create --styles)共用 +// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用 // parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。 func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) { for _, tc := range []struct { diff --git a/internal/helpers/sheet_workbook.go b/internal/helpers/sheet_workbook.go index 63b37ff3..f8d8a422 100644 --- a/internal/helpers/sheet_workbook.go +++ b/internal/helpers/sheet_workbook.go @@ -10,52 +10,13 @@ import ( func newWorkbookCmds() []*cobra.Command { createCmd := &cobra.Command{ Use: "create", - Short: "创建钉钉表格文档(可带初始数据)", - Long: `创建一篇新的钉钉在线电子表格,支持创建空表格或创建时写入初始数据。 + Short: "创建钉钉表格文档", + Long: `创建一篇新的钉钉在线电子表格,支持创建空表格。 -创建位置优先级: --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,可选,需与 --values 或 --sheets 搭配;顶层键对齐飞书 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`, +创建位置优先级: --folder > --workspace > 默认 (我的文档根目录)`, Example: ` dws sheet create --name "销售数据" dws sheet create --name "Q1 数据" --folder FOLDER_ID - dws sheet create --name "知识库表格" --workspace WS_ID - - # 创建并写入初始数据(默认工作表,裸二维值) - dws sheet create --name "名单" --values '[["姓名","分数"],["张三","90"]]' - - # 创建多个带数据的工作表(typed table) - dws sheet create --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' - - # 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽、合并) - dws sheet create --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 --name "知识库表格" --workspace WS_ID`, RunE: func(cmd *cobra.Command, args []string) error { toolArgs := map[string]any{ "name": mustGetFlag(cmd, "name"), @@ -66,21 +27,7 @@ func newWorkbookCmds() []*cobra.Command { if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" { toolArgs["workspaceId"] = v } - - valuesStr, _ := cmd.Flags().GetString("values") - sheetsStr, _ := cmd.Flags().GetString("sheets") - stylesStr, _ := cmd.Flags().GetString("styles") - if valuesStr == "" && sheetsStr == "" { - if stylesStr != "" { - return fmt.Errorf("--styles 需与 --values 或 --sheets 搭配使用") - } - // 无初始数据:保持原行为(创建空表并直接输出结果) - return callMCPTool("create_workspace_sheet", toolArgs) - } - if valuesStr != "" && sheetsStr != "" { - return fmt.Errorf("--values 与 --sheets 二选一,不能同时指定") - } - return runCreateSheetWithData(cmd, toolArgs, valuesStr, sheetsStr, stylesStr) + return callMCPTool("create_workspace_sheet", toolArgs) }, } DeclareLeafMetadata(createCmd, LeafSpec{ @@ -97,12 +44,6 @@ func newWorkbookCmds() []*cobra.Command { PrimaryCLIPath: "sheet create", }, Description: "创建钉钉在线电子表格文档(axls),返回 nodeId。", - // interface_ref 声明主工具 create_workspace_sheet,与 main 一致。带 - // --values / --sheets / --styles 时 CLI 会在建文档后编排 get_all_sheets / - // update_sheet / table_put / set_range_from_csv / get_range_as_csv / - // set_cell_range / update_dimension / merge_cells,但这些是建文档之后的 - // 实现细节。沿用既有的「声明主工具」惯例(参见 doc.create_document 同样在 - // create 后追加 update_document 却声明 mcp),不将 create 改判为 composite。 Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", @@ -128,9 +69,6 @@ func newWorkbookCmds() []*cobra.Command { createCmd.Flags().String("name", "", "表格名称 (必填)") createCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL") createCmd.Flags().String("workspace", "", "目标知识库 ID") - createCmd.Flags().String("values", "", "初始数据,二维 JSON 数组,写入默认工作表 (与 --sheets 二选一)") - createCmd.Flags().String("sheets", "", `多工作表 typed table JSON,如 '[{"name":"表名","columns":[...],"data":[[...]]}]' (与 --values 二选一)`) - createCmd.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"}]}]}`) listCmd := &cobra.Command{ Use: "list", diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index 4a11dab6..42e77703 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -12,7 +12,7 @@ - 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) 用户说"导出 CSV/存成 csv/导出这个工作表为 csv": -- 导出单个工作表为纯 CSV → `export --export-format csv`(**同步**,不走异步任务) +- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) - 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式 - 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理 @@ -34,13 +34,8 @@ Example: dws sheet export --node --output ./ Flags: - --node string 表格文档 ID 或 URL (必填) - --output string 本地保存路径(可选,支持文件路径或目录) - --export-format string 导出格式: xlsx(默认,异步任务) / csv(单个工作表,同步) (default "xlsx") - --sheet-id string 工作表 ID 或名称(仅 --export-format csv,不传则第一个) - --range string 导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出) - --value-render-option string 取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula - --allow-truncated 允许 CSV 被截断时仍然导出(仅 --export-format csv)。默认截断即报错且不写文件 + --node string 表格文档 ID 或 URL (必填) + --output string 本地保存路径(可选,支持文件路径或目录) ``` 将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 @@ -50,33 +45,6 @@ Flags: 2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 -**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` / `--allow-truncated` 生效。不传 `--output` 时 CSV 正文打印到 stdout。 - -**csv 专属参数漏写 `--export-format csv` 会直接报错**:`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated` 只在 csv 分支生效,传给 xlsx 分支时命令报错而不是静默忽略——否则本想按 `--range` 导一小块,实际会拿到整篇工作簿且报成功。 - -**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。 - -**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: -- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) -- 改用默认的 `--export-format xlsx` 导出完整表格 -- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格" - -```bash -# 落盘到本地 -dws sheet export --node --export-format csv --sheet-id --output ./data.csv - -# 输出到 stdout 便于管道处理 -dws sheet export --node --export-format csv --sheet-id - -# 大表分块导出(避免截断;不分块时默认会因截断而报错) -dws sheet export --node --export-format csv --sheet-id --range "A1:Z1000" --output ./part1.csv - -# 明确接受不完整数据(否则截断即失败) -dws sheet export --node --export-format csv --sheet-id --allow-truncated --output ./partial.csv -``` - -注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用默认的 xlsx。 - **内置轮询策略(CLI 内实现,无需关心)**: - 第 1~5 次:每次间隔 2 秒 - 第 6~10 次:每次间隔 5 秒 @@ -95,6 +63,49 @@ dws sheet export --node --export-format csv --sheet-id --al **限制**:仅支持钉钉在线电子表格(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 @@ -120,6 +131,7 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 @@ -130,3 +142,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-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index f97cfd57..6cbe121c 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` @@ -43,6 +44,21 @@ 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 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 @@ -52,7 +68,9 @@ Flags: --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) ``` -**创建时写入初始数据**(`--values` 与 `--sheets` 二选一,都不传则创建空表): +建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 + +**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): - `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 - `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 @@ -92,17 +110,17 @@ Flags: ```bash # 创建并写入初始数据(默认工作表,裸二维值) -dws sheet create --name "名单" --values '[["姓名","分数"],["张三","90"]]' +dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' # 创建多个带数据的工作表 -dws sheet create --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' +dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' # 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽) -dws sheet create --name "带样式" --values '[["姓名","分数"],["张三","90"]]' \ +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}]}]}' ``` -> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 +> `--folder` / `--workspace` 的 ID 格式约束与 `dws sheet create` 完全一致(只接受 UUID 格式的 `fileId` 或 alidocs URL)。 ### 获取全部工作表列表 ``` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md index 1dfbdb37..1fd50367 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -12,7 +12,7 @@ - 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) 用户说"导出 CSV/存成 csv/导出这个工作表为 csv": -- 导出单个工作表为纯 CSV → `export --export-format csv`(**同步**,不走异步任务) +- 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) - 用 `--sheet-id` 指定工作表(不传取第一个)、`--range` 限定范围、`--value-render-option` 选取值模式 - 不传 `--output` 时 CSV 正文打印到 stdout,可直接管道处理 @@ -34,13 +34,8 @@ Example: dws sheet export --node --output ./ Flags: - --node string 表格文档 ID 或 URL (必填) - --output string 本地保存路径(可选,支持文件路径或目录) - --export-format string 导出格式: xlsx(默认,异步任务) / csv(单个工作表,同步) (default "xlsx") - --sheet-id string 工作表 ID 或名称(仅 --export-format csv,不传则第一个) - --range string 导出范围,A1 表示法(仅 --export-format csv,不传则整表;大表可用此分块导出) - --value-render-option string 取值模式(仅 --export-format csv): formatted_value(默认) / raw_value / formula - --allow-truncated 允许 CSV 被截断时仍然导出(仅 --export-format csv)。默认截断即报错且不写文件 + --node string 表格文档 ID 或 URL (必填) + --output string 本地保存路径(可选,支持文件路径或目录) ``` 将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 @@ -50,33 +45,6 @@ Flags: 2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 -**`--export-format csv`(同步路径)**:不走异步任务,直接读取单个工作表并输出 RFC4180 CSV。仅 `--sheet-id` / `--range` / `--value-render-option` / `--output` / `--allow-truncated` 生效。不传 `--output` 时 CSV 正文打印到 stdout。 - -**csv 专属参数漏写 `--export-format csv` 会直接报错**:`--sheet-id` / `--range` / `--value-render-option` / `--allow-truncated` 只在 csv 分支生效,传给 xlsx 分支时命令报错而不是静默忽略——否则本想按 `--range` 导一小块,实际会拿到整篇工作簿且报成功。 - -**`--output` 落盘是原子替换**:CSV 先写同目录临时文件、成功后再替换目标,写入失败时已有文件保持原样(父目录不存在仍按错误处理,不会自动创建)。 - -**超大表默认 fail-closed**:数据超出单次读取上限(服务端返回 `hasMore`)时,命令**直接报错并以非 0 退出,既不打印 CSV 也不写文件**(已存在的目标文件不会被截断数据覆盖)。处理方式: -- 用 `--range` 分块导出(如 `--range A1:Z1000`、`A1001:Z2000` …) -- 改用默认的 `--export-format xlsx` 导出完整表格 -- 确认可以接受不完整数据时,显式加 `--allow-truncated`;此时才会照常输出/落盘,并在 stderr 给出「已被截断」警告,成功提示也会写明"数据已截断,不是完整表格" - -```bash -# 落盘到本地 -dws sheet export --node --export-format csv --sheet-id --output ./data.csv - -# 输出到 stdout 便于管道处理 -dws sheet export --node --export-format csv --sheet-id - -# 大表分块导出(避免截断;不分块时默认会因截断而报错) -dws sheet export --node --export-format csv --sheet-id --range "A1:Z1000" --output ./part1.csv - -# 明确接受不完整数据(否则截断即失败) -dws sheet export --node --export-format csv --sheet-id --allow-truncated --output ./partial.csv -``` - -注意:CSV 只写纯值,不保留样式/合并/公式;需要完整属性请用默认的 xlsx。 - **内置轮询策略(CLI 内实现,无需关心)**: - 第 1~5 次:每次间隔 2 秒 - 第 6~10 次:每次间隔 5 秒 @@ -95,6 +63,49 @@ dws sheet export --node --export-format csv --sheet-id --al **限制**:仅支持钉钉在线电子表格(alxs)→ 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 @@ -129,6 +140,7 @@ dws sheet export --node --format json |------|-------------|------| | `export` | `downloadUrl`(未指定 --output)/ `导出完成: `(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | | `export` 超时中断 | 错误信息 | 告知用户稍后重试 | +| `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 @@ -139,3 +151,5 @@ dws sheet export --node --format json - `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-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index ea8b07a9..b070f13f 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` @@ -45,6 +46,21 @@ 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 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 @@ -54,7 +70,9 @@ Flags: --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) ``` -**创建时写入初始数据**(`--values` 与 `--sheets` 二选一,都不传则创建空表): +建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 + +**写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): - `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 - `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 @@ -94,17 +112,17 @@ Flags: ```bash # 创建并写入初始数据(默认工作表,裸二维值) -dws sheet create --name "名单" --values '[["姓名","分数"],["张三","90"]]' +dws sheet create-with-data --name "名单" --values '[["姓名","分数"],["张三","90"]]' # 创建多个带数据的工作表 -dws sheet create --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' +dws sheet create-with-data --name "报表" --sheets '[{"name":"一月","columns":["项目","金额"],"data":[["房租",5000]]},{"name":"二月","columns":["项目","金额"],"data":[["房租",5000]]}]' # 创建 + 写数据 + 一并应用样式(表头加粗黄底、行高、列宽) -dws sheet create --name "带样式" --values '[["姓名","分数"],["张三","90"]]' \ +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}]}]}' ``` -> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。 +> `--folder` / `--workspace` 的 ID 格式约束与 `dws sheet create` 完全一致(只接受 UUID 格式的 `fileId` 或 alidocs URL)。 ### 获取全部工作表列表 ``` From 6f61183732bfd58a7b4af23a109eccdcc8b8a387 Mon Sep 17 00:00:00 2001 From: "huwenjiao.hwj" Date: Sun, 9 Aug 2026 18:33:56 +0800 Subject: [PATCH 25/35] fix(sheet): reject sheet prefixes that are blank after trimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --ranges validated the position of "!" in the raw string and then returned the trimmed halves, so " !A1:B2" was accepted and produced a set_cell_range / clear_range operation carrying sheetId: "". Depending on how the server treats an empty sheetId, the whole batch_update fails, or — worse — the operation lands on the default worksheet instead of the one the user named, while the command reports success. Both halves must now be non-empty *after* trimming. batch-clear grew the same hole independently (it duplicated the split inline); it now shares splitSheetPrefixedRange, so the invariant holds by construction rather than by being repeated correctly in two places. batch-set-style --batch had the same gap at the JSON level: it only rejected sheetId == "", so " " passed. It now judges the trimmed value but still sends the raw one — sheetId may be a worksheet *name*, and names may legitimately carry leading or trailing spaces, so trimming on the user's behalf would target a different sheet. The --ranges form cannot express such a name anyway, which is what --batch is for. TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall covers all three entry points with calls == 0; TestBatchStyleSheetIDIsSentVerbatimNotTrimmed pins the no-normalisation half. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + internal/helpers/sheet_batch.go | 10 +- internal/helpers/sheet_style.go | 18 ++- .../helpers/sheet_style_batch_budget_test.go | 118 ++++++++++++++++++ 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c51a12..dfbf20b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`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 严格校验,不受影响。 ## [1.0.58-beta.1] - 2026-08-07 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_style.go b/internal/helpers/sheet_style.go index b09cd436..3e3cb87a 100644 --- a/internal/helpers/sheet_style.go +++ b/internal/helpers/sheet_style.go @@ -736,12 +736,20 @@ func addBatchStyleCells(total *int64, rows, cols int) error { } // 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 || i == len(rng)-1 { + if i < 0 { return "", "", fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", idx, rng) } - return strings.TrimSpace(rng[:i]), strings.TrimSpace(rng[i+1:]), nil + 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。 @@ -807,7 +815,11 @@ func buildBatchStyleOpsFromFile(batchPath string) ([]any, error) { operations := make([]any, 0, total) var totalCells int64 for i, item := range items { - if item.SheetID == "" || item.Range == "" { + // 判空看修剪后的值:" " 这类纯空白与漏填等价,放过去就是一次 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) diff --git a/internal/helpers/sheet_style_batch_budget_test.go b/internal/helpers/sheet_style_batch_budget_test.go index 8d23c0a7..babe7176 100644 --- a/internal/helpers/sheet_style_batch_budget_test.go +++ b/internal/helpers/sheet_style_batch_budget_test.go @@ -229,6 +229,18 @@ func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) { 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"]`, @@ -256,3 +268,109 @@ func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) { }) } } + +// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。 +// +// 只按原始串里 ! 的位置判断," !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 ") + } +} From 6be12655dc4ba7f8f559542985e4d04cdc90e7a9 Mon Sep 17 00:00:00 2001 From: xiatian Date: Mon, 10 Aug 2026 12:06:07 +0800 Subject: [PATCH 26/35] fix(skills): address sheet review feedback --- docs/skill-content-framework.md | 2 +- docs/skill-mono-multi-qa.md | 15 +- docs/skill-wukong-align-plan.md | 2 +- .../policy/check-mono-multi-skill-content.sh | 2 +- skills/content-qa/mono-multi-coverage.yaml | 10 +- skills/mono/references/products/sheet.md | 3 +- .../multi/dingtalk-misc/references/sheet.md | 1 + test/unit/mono_multi_skill_content_test.go | 419 +++++++++++++++++- 8 files changed, 422 insertions(+), 32 deletions(-) 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/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/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index b33a6692..96b8b55c 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -142,11 +142,17 @@ omit_global: disposition: covered_by reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名" -# Reviewed file pairs. Most are byte-identical; layout-only link differences use an explicit normalization mode. +# 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: shared-link-normalized + 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 diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 26fcc52c..4276456d 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -258,6 +258,7 @@ Flags: - 多维表 / 字段类型 → 切到 `dingtalk-aitable` - 把表数据写进文档 → 切到 `dingtalk-doc` + ## 局部意图与 Recipe -- [局部意图消歧](sheet-intent-guide.md)。 +- [局部意图消歧](../intent-guide.md)。 diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index fcb10996..c884f44a 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -258,6 +258,7 @@ Flags: - 多维表 / 字段类型 → 切到 `dingtalk-aitable` - 把表数据写进文档 → 切到 `dingtalk-doc` + ## 局部意图与 Recipe - [局部意图消歧](sheet-intent-guide.md)。 diff --git a/test/unit/mono_multi_skill_content_test.go b/test/unit/mono_multi_skill_content_test.go index 1f958f3f..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,9 +44,10 @@ 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"` + 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"` @@ -58,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) @@ -260,26 +282,8 @@ func TestMonoMultiSkillContentG4Drift(t *testing.T) { if mode == "" { mode = "identical" } - switch mode { - case "identical": - if !bytes.Equal(monoData, multiData) { - t.Errorf("G4: paired files differ: mono %s vs multi %s", pair.Mono, pair.Multi) - } - case "shared-link-normalized": - monoLink := []byte("../url-patterns.md") - multiLink := []byte("../../dingtalk-shared/references/url-patterns.md") - if bytes.Count(monoData, monoLink) != 1 || bytes.Count(multiData, multiLink) != 1 { - t.Errorf("G4: normalized shared-link pair must contain exactly one layout-specific URL-pattern link: mono %s vs multi %s", pair.Mono, pair.Multi) - continue - } - marker := []byte("__DWS_SHARED_URL_PATTERNS__") - monoNormalized := bytes.Replace(monoData, monoLink, marker, 1) - multiNormalized := bytes.Replace(multiData, multiLink, marker, 1) - if !bytes.Equal(monoNormalized, multiNormalized) { - t.Errorf("G4: paired files differ after shared-link normalization: mono %s vs multi %s", pair.Mono, pair.Multi) - } - default: - t.Errorf("G4: unknown paired mode %q", mode) + 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) } } @@ -378,6 +382,367 @@ 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 { @@ -385,6 +750,12 @@ func readRelativeFileTree(root string) (map[string][]byte, error) { 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) From cd02fe71e6c986887864e3887056a7c4802abbc8 Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 10 Aug 2026 14:24:07 +0800 Subject: [PATCH 27/35] ci(interface): allow reviewed flag type migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoritative interface baseline and command-compatibility gates rejected every flag type change on a historical command, with no review channel — even when the new type only moves the same validation from RunE to flag parsing. Both now consult a precise allowlist. An entry must match command path, flag name and both type names exactly, so it is direction-sensitive by construction, and it applies only when nothing else about the flag moved (shorthand, required, hidden, no-opt, scope). A bundled regression re-reports the type change. The first and only entry is "dws minutes permission apply --policy" moving from string to int (for #912): the old RunE parsed with strconv.ParseInt(v, 10, 64) and enforced [2,4], the new one lets pflag parse with base 0 and still enforces [2,4], so the historical set of successful invocations is a subset of the new one. Base 0 additionally accepts spellings like "0x3", which widens rather than narrows. Defaults are excluded from the guard because the migration necessarily changes one. In the snapshot gate the exemption resolves against the canonical Command.Path, never the alias-expanded accepted path: an aliased command is compared once per accepted spelling, so keying on that would let every alias bypass the table. The table is duplicated because 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. A guard test fails if the two copies drift. --- CHANGELOG.md | 1 + internal/interfacesnapshot/compare.go | 25 +- internal/interfacesnapshot/compare_test.go | 225 +++++++++++++++++- internal/interfacesnapshot/reviewed.go | 121 ++++++++++ scripts/policy/interface-baseline/contract.go | 30 ++- .../interface-baseline/contract_test.go | 221 +++++++++++++++++ scripts/policy/interface-baseline/reviewed.go | 140 +++++++++++ 7 files changed, 746 insertions(+), 17 deletions(-) create mode 100644 internal/interfacesnapshot/reviewed.go create mode 100644 scripts/policy/interface-baseline/reviewed.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 124df785..6afb3a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **`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 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/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..4d7a9657 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,226 @@ 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") + } +} + +// 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 +} From 43121f1d8f1906babdfd429d8955e85f4918fc03 Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 10 Aug 2026 15:02:08 +0800 Subject: [PATCH 28/35] test(interface): cover the merge-path bundled-regression branches mergedFlagContractOtherwiseChanged was only ever exercised on the path where every condition holds still, because `||` short-circuits: with no reviewed entry the first operand already decides the outcome and the function is never called at all. That left its five regression branches uncovered and put changed-code coverage at 88.0952% against a 100% target. Add the merge-path counterpart of the checkCompatibility bundled-regression table, pairing each of shorthand / required / hidden / no-opt / scope with the reviewed type change and requiring the type failure to reappear. Changed-code coverage is now 100%. --- .../interface-baseline/contract_test.go | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/scripts/policy/interface-baseline/contract_test.go b/scripts/policy/interface-baseline/contract_test.go index 4d7a9657..c2838d5f 100644 --- a/scripts/policy/interface-baseline/contract_test.go +++ b/scripts/policy/interface-baseline/contract_test.go @@ -401,6 +401,66 @@ func TestMergeAcceptsReviewedFlagTypeChangeAndKeepsHistoricalType(t *testing.T) } } +// 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 From 2cc24de505d236097282efe27bfc1950068ed72c Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 10 Aug 2026 15:55:25 +0800 Subject: [PATCH 29/35] chore(release): seal v1.0.58-beta.2 changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afb3a99..0525e085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [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 的入参。 @@ -17,6 +19,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### 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 From 867bb44586196dce5b13ee2c3b13af2505928d86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:22:55 +0000 Subject: [PATCH 30/35] chore: update beta formula for v1.0.58-beta.2 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 From 5c33ea526eb6b7e503d033b220915c29002dd052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Thu, 6 Aug 2026 00:48:41 +0800 Subject: [PATCH 31/35] feat(chat): support mentions in streaming card creation --- CHANGELOG.md | 6 ++ internal/shortcut/chat/chat_message.go | 34 ++++++++--- internal/shortcut/chat/gap_fill_test.go | 57 +++++++++++++++++++ skills/multi/dingtalk-chat/SKILL.md | 2 +- .../dingtalk-chat/references/card/create.md | 7 +++ .../references/chat/chat-message.md | 3 +- 6 files changed, 99 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0525e085..27704f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ 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 only to the initial card-creation request. + ## [1.0.58-beta.2] - 2026-08-10 ### Added diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 72328efd..576b89de 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 在创建卡片时设置 @对象,后续流式更新不重复传递。--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,29 +1454,33 @@ 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.", }, 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 在创建卡片时设置 @对象,后续流式更新不重复传递。--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: "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: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"}, {Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"}, }, @@ -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 { @@ -1509,6 +1521,12 @@ var MessagesSendCard = shortcut.Shortcut{ switch { case group != "": params["openConversationId"] = group + if atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(atOpenIDs) > 0 { + params["atOpenDingTalkIds"] = atOpenIDs + } + if rt.Bool("at-all") { + params["atAll"] = true + } case receiver != "": openID, err := resolveUserOpenDingTalkID(rt, receiver) if err != nil { diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index d2c618b9..acbbdcf5 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -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,6 +632,18 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { fake.calls[1].tool != "update_streaming_card" { t.Fatalf("card calls = %#v", fake.calls) } + 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) @@ -748,6 +762,47 @@ 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) + } + }) + t.Run("receiver resolution error", func(t *testing.T) { fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"} helpers.InitDeps(fake) @@ -815,6 +870,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() diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 6063d698..fc9c2b5b 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`;群聊创建时可传 `--at-open-dingtalk-ids` 或 `--at-all`,艾特对象只进入 create;当前只支持 streaming text create/update,不支持 Card JSON 或 callback。 ## 关键结果语义 diff --git a/skills/multi/dingtalk-chat/references/card/create.md b/skills/multi/dingtalk-chat/references/card/create.md index 41a8ed33..35d88050 100644 --- a/skills/multi/dingtalk-chat/references/card/create.md +++ b/skills/multi/dingtalk-chat/references/card/create.md @@ -7,9 +7,16 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传 - 只传目标:创建卡片并从真实结果取得 `bizId`,供后续更新。 - 同时传 `--content`:Runtime 串行执行 create → 从返回提取 `bizId` → update;默认 `--flow-status 3`。 +- 群聊可传 `--at-open-dingtalk-ids` 或 `--at-all`;艾特对象只进入初始 + `create_and_send_card`,不会进入后续 `update_streaming_card`。 - `--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 31e8c6eb..b425cf40 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -286,12 +286,13 @@ 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`;两种艾特参数只随创建请求发送,更新阶段不再携带。 当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见 [card references](../card/schema.md)。 From ef29b48a55f6c26587961226e535df66749ee762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Thu, 6 Aug 2026 09:23:38 +0800 Subject: [PATCH 32/35] fix(chat): keep skill within context budget --- skills/multi/dingtalk-chat/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index fc9c2b5b..f23ebcd6 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`;群聊创建时可传 `--at-open-dingtalk-ids` 或 `--at-all`,艾特对象只进入 create;当前只支持 streaming text create/update,不支持 Card JSON 或 callback。 +- 流式卡片用 `+messages-send-card`;群聊创建可传 `--at-open-dingtalk-ids` 或 `--at-all`,@对象只进 create;仅支持 streaming text,不支持 Card JSON/callback。 ## 关键结果语义 From c7148f3ebbafd34c60e756721a60376c6f28eef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Thu, 6 Aug 2026 11:21:04 +0800 Subject: [PATCH 33/35] docs(chat): clarify streaming card mention content --- skills/multi/dingtalk-chat/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index f23ebcd6..aa737e88 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`;群聊创建可传 `--at-open-dingtalk-ids` 或 `--at-all`,@对象只进 create;仅支持 streaming text,不支持 Card JSON/callback。 +- 流式卡片用 `+messages-send-card`;群聊@只传 `--at-open-dingtalk-ids`/`--at-all` 到 create;`--content` 原样显示,禁写 ID/占位符;仅text,不支持 Card JSON/callback。 ## 关键结果语义 From a0d59a79aaf47638af6c8c2bd4358a053124134b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Fri, 7 Aug 2026 13:47:55 +0800 Subject: [PATCH 34/35] feat(chat): prepend card mention tag to content --- CHANGELOG.md | 3 +- internal/shortcut/chat/chat_message.go | 38 ++++++++++----- internal/shortcut/chat/gap_fill_test.go | 47 ++++++++++++++++++- skills/multi/dingtalk-chat/SKILL.md | 2 +- .../dingtalk-chat/references/card/create.md | 4 +- .../references/chat/chat-message.md | 3 +- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27704f89..6d9494c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **Streaming-card mentions** — `chat +messages-send-card` now accepts `--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention - targets only to the initial card-creation request. + 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 diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 576b89de..60e6aed5 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -1440,7 +1440,7 @@ var MessagesSendCard = shortcut.Shortcut{ Command: "+messages-send-card", Product: "im", Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人", - Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象,后续流式更新不重复传递。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。", + 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", @@ -1458,11 +1458,11 @@ var MessagesSendCard = shortcut.Shortcut{ 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。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象,后续流式更新不重复传递。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"}, + 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 --at-open-dingtalk-ids --content \"任务已完成\"", @@ -1481,7 +1481,7 @@ var MessagesSendCard = shortcut.Shortcut{ {Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"}, {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: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"}, + {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{ @@ -1518,14 +1518,17 @@ 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) @@ -1542,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, @@ -1558,7 +1565,7 @@ var MessagesSendCard = shortcut.Shortcut{ "tool": "update_streaming_card", "arguments": map[string]any{ "bizId": "", - "msgContent": content, + "msgContent": plannedContent, "flowStatus": status, }, }, @@ -1573,9 +1580,10 @@ var MessagesSendCard = shortcut.Shortcut{ if bizID == "" { return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应") } + atTag := findCardAtTag(created) updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{ "bizId": bizID, - "msgContent": content, + "msgContent": atTag + content, "flowStatus": status, }) if err != nil { @@ -1593,12 +1601,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 } } @@ -1613,7 +1627,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 } } @@ -1626,13 +1640,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 } } @@ -1641,7 +1655,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 acbbdcf5..2a2b73f1 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) @@ -644,7 +644,8 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) { 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"] != "完成" || + 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) } @@ -657,6 +658,27 @@ 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 TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) { fake := &larkAlignmentCaller{} helpers.InitDeps(fake) @@ -801,6 +823,9 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test 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) { @@ -915,6 +940,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/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index aa737e88..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`;群聊@只传 `--at-open-dingtalk-ids`/`--at-all` 到 create;`--content` 原样显示,禁写 ID/占位符;仅text,不支持 Card JSON/callback。 +- 流式卡片用 `+messages-send-card`;群聊@传 ID/`--at-all`,Runtime 把 create 返回前缀加到 `--content`;禁写占位符;仅 text。 ## 关键结果语义 diff --git a/skills/multi/dingtalk-chat/references/card/create.md b/skills/multi/dingtalk-chat/references/card/create.md index 35d88050..e7a1fbed 100644 --- a/skills/multi/dingtalk-chat/references/card/create.md +++ b/skills/multi/dingtalk-chat/references/card/create.md @@ -8,7 +8,9 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传 - 同时传 `--content`:Runtime 串行执行 create → 从返回提取 `bizId` → update;默认 `--flow-status 3`。 - 群聊可传 `--at-open-dingtalk-ids` 或 `--at-all`;艾特对象只进入初始 - `create_and_send_card`,不会进入后续 `update_streaming_card`。 + `create_and_send_card`。同一次调用带 `--content` 时,Runtime 将 create 返回的 + `atTag` 自动加在正文前,再调用 `update_streaming_card`;调用方不要拼 ID + 或艾特占位符。 - `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。 创建成功但自动更新失败时,错误会保留真实 `bizId`。不要重复创建;使用该 `bizId` 继续 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index b425cf40..89099201 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -292,7 +292,8 @@ dws chat +messages-update-card --biz-id --content "最终内容" --flow- ``` `flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。 -群聊还可传 `--at-all`;两种艾特参数只随创建请求发送,更新阶段不再携带。 +群聊还可传 `--at-all`;两种艾特参数只随创建请求发送。send-card 同时带正文时, +Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID 或占位符。 当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见 [card references](../card/schema.md)。 From 258caa5906f7ffe1dce8c2114044d2904407f91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=89=8D=E6=B4=A5?= Date: Mon, 10 Aug 2026 16:37:04 +0800 Subject: [PATCH 35/35] fix(chat): fail safely when card mention tag is missing --- internal/shortcut/chat/chat_message.go | 3 +++ internal/shortcut/chat/gap_fill_test.go | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 60e6aed5..5a1f7576 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -1581,6 +1581,9 @@ var MessagesSendCard = shortcut.Shortcut{ 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": atTag + content, diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 2a2b73f1..a118157a 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -679,6 +679,28 @@ func TestCrossPlatformCoverageMessagesSendCardKeepsContentWithoutAtTag(t *testin } } +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)