diff --git a/.changes/1009-clitrack-telemetry.md b/.changes/released/1.0.59-beta.2/1009-clitrack-telemetry.md similarity index 100% rename from .changes/1009-clitrack-telemetry.md rename to .changes/released/1.0.59-beta.2/1009-clitrack-telemetry.md diff --git a/.changes/1019-help-feedback-entry.md b/.changes/released/1.0.59-beta.2/1019-help-feedback-entry.md similarity index 100% rename from .changes/1019-help-feedback-entry.md rename to .changes/released/1.0.59-beta.2/1019-help-feedback-entry.md diff --git a/.changes/954-chat-im-id-flags.md b/.changes/released/1.0.59-beta.2/954-chat-im-id-flags.md similarity index 100% rename from .changes/954-chat-im-id-flags.md rename to .changes/released/1.0.59-beta.2/954-chat-im-id-flags.md diff --git a/.changes/chat-reference-card-verification.md b/.changes/released/1.0.59-beta.2/chat-reference-card-verification.md similarity index 100% rename from .changes/chat-reference-card-verification.md rename to .changes/released/1.0.59-beta.2/chat-reference-card-verification.md diff --git a/.changes/schema-catalog-json-projection.md b/.changes/released/1.0.59-beta.2/schema-catalog-json-projection.md similarity index 100% rename from .changes/schema-catalog-json-projection.md rename to .changes/released/1.0.59-beta.2/schema-catalog-json-projection.md diff --git a/.changes/wiki-shortcut-alignment.md b/.changes/released/1.0.59-beta.2/wiki-shortcut-alignment.md similarity index 100% rename from .changes/wiki-shortcut-alignment.md rename to .changes/released/1.0.59-beta.2/wiki-shortcut-alignment.md diff --git a/.changes/sheet-source-range-dropdowns.md b/.changes/sheet-source-range-dropdowns.md new file mode 100644 index 00000000..91159e61 --- /dev/null +++ b/.changes/sheet-source-range-dropdowns.md @@ -0,0 +1,6 @@ +--- +category: Added +--- + +- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported. +- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ded760c7..6dc6406c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,12 +529,22 @@ jobs: # shards at full-suite scope; release-scripts is included because its # dedicated job only runs at full-suite or release-sensitive scope, and # dropping it here would stop testing test/scripts changes entirely. + # internal/app is carried by one shard per bounded partition rather than a + # single app shard: the partitions used to run end to end inside one job, + # where the Schema partition alone owned most of the wall clock. The + # app- names are pinned to the helper's partition set by + # TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose + # its job silently. timeout-minutes: 20 strategy: fail-fast: false matrix: shard: - - app + - app-schema + - app-a-b + - app-c + - app-d-r + - app-s-z-example-fuzz - generators - helpers - cli @@ -576,9 +586,16 @@ jobs: TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail + # Every app partition shard tests the same single internal/app + # package, so the impacted-package query uses the base shard name and + # the partition only selects which tests run. + package_shard="$TEST_SHARD" + case "$TEST_SHARD" in + app-*) package_shard=app ;; + esac package_output="$( ./scripts/ci/changed-test-packages.sh \ - list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF" + list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF" )" if [ -z "$package_output" ]; then echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \ @@ -618,14 +635,19 @@ jobs: exit 1 } done - if [ "$TEST_SHARD" = "app" ]; then - # A single long-lived app test process retains every constructed - # command tree in framework registries. Isolate Schema assembly and - # bounded name ranges so each process releases that state on exit. - test "${#packages[@]}" -eq 1 - ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" - exit 0 - fi + case "$TEST_SHARD" in + app-*) + # A single long-lived app test process retains every constructed + # command tree in framework registries. Each partition is its own + # job, so that state is released when the process exits and the + # partitions run concurrently instead of end to end. The helper + # still verifies that the partition patterns cover every top-level + # test exactly once before running the one it was asked for. + test "${#packages[@]}" -eq 1 + ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}" + exit 0 + ;; + esac if [ "$TEST_SHARD" = "release-scripts" ]; then # Mirror the dedicated release-contract job: these suites shell out # to archive tooling and are not race-instrumented there. @@ -646,14 +668,21 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} runs-on: ubuntu-latest - # app runs several independently bounded processes; cli/smoke need headroom - # beyond go test -timeout for setup + assembly. + # internal/app is split across one shard per bounded partition so the + # partitions run concurrently and each releases its framework registries + # when the process exits; cli/smoke need headroom beyond go test -timeout for + # setup + assembly. The app- names are pinned to the helper's + # partition set by TestCIAppRacePartitionMatrixMatchesHelper. timeout-minutes: 20 strategy: fail-fast: false matrix: shard: - - app + - app-schema + - app-a-b + - app-c + - app-d-r + - app-s-z-example-fuzz - generators - helpers - cli @@ -679,18 +708,30 @@ jobs: TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail - package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")" + # Every app partition shard tests the same single internal/app + # package, so the package query uses the base shard name and the + # partition only selects which tests run. + package_shard="$TEST_SHARD" + case "$TEST_SHARD" in + app-*) package_shard=app ;; + esac + package_output="$(./scripts/ci/test-packages.sh list "$package_shard")" test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 - if [ "$TEST_SHARD" = "app" ]; then - # A single long-lived app test process retains every constructed - # command tree in framework registries. Isolate Schema assembly and - # bounded name ranges so each process releases that state on exit. - test "${#packages[@]}" -eq 1 - ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" - exit 0 - fi + case "$TEST_SHARD" in + app-*) + # A single long-lived app test process retains every constructed + # command tree in framework registries. Each partition is its own + # job, so that state is released when the process exits and the + # partitions run concurrently instead of end to end. The helper + # still verifies that the partition patterns cover every top-level + # test exactly once before running the one it was asked for. + test "${#packages[@]}" -eq 1 + ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}" + exit 0 + ;; + esac # cli/smoke own heavy NewRootCommand / Schema assembly under -race; # give them a dedicated package timeout on slower hosted runners. timeout_budget=12m diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1f2dfc..9afe33b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.59-beta.2] - 2026-08-17 + +### Added + +- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting. + +- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form. + +- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and + activity shortcuts with strict collection validation, cursor handling, + write-terminal evidence, safe read-backs where the backend supports them, + task-oriented routing, and documented backend + boundaries. + +### Changed + +- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags. +- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces. +- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name. + +- **Faster Schema Catalog assembly** — projects typed values into payload JSON + without re-running a validation scan over documents `json.Marshal` has just + produced, cutting roughly a third of the projection work across the full tool + set. Untrusted JSON input keeps its existing validation. + +### Fixed + +- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write. +- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent. + + ## [1.0.59-beta.1] - 2026-08-14 ### Added diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 98fc206f..1d76aa1f 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.59-beta.1" + version "1.0.59-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.59-beta.1/dws-darwin-arm64.tar.gz" - sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz" + sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz" - sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz" + sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz" - sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz" + sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz" - sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz" + sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip" - sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip" + sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f" end def install diff --git a/internal/helpers/sheet_batch.go b/internal/helpers/sheet_batch.go index 218a88fd..7ce6e17d 100644 --- a/internal/helpers/sheet_batch.go +++ b/internal/helpers/sheet_batch.go @@ -60,6 +60,11 @@ func translateBatchOp(op map[string]any) (map[string]any, error) { if !ok { return nil, fmt.Errorf("unsupported toolName %q: must be a CLI command name (e.g. \"range clear\", \"range update\", \"merge-cells\"). Run 'dws sheet batch-update --help' for the full list", toolName) } + if toolName == "set-dropdown" { + if err := validateBatchSetDropdownInput(input); err != nil { + return nil, err + } + } return map[string]any{ "toolName": mapping.mcpTool, @@ -67,6 +72,33 @@ func translateBatchOp(op map[string]any) (map[string]any, error) { }, nil } +func validateBatchSetDropdownInput(input map[string]any) error { + if _, exists := input["source-colors"]; exists { + return fmt.Errorf("set-dropdown: 顶层 source-colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持") + } + if _, exists := input["colors"]; exists { + return fmt.Errorf("set-dropdown: 顶层 colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持") + } + + options, hasOptions := input["options"] + hasOptions = hasOptions && options != nil + sourceRange := batchStr(input, "source-range") + sourceSheetID := batchStr(input, "source-sheet-id") + hasSourceRange := sourceRange != "" + if hasOptions == hasSourceRange { + return fmt.Errorf("set-dropdown: options 与 source-range 必须且只能指定一个") + } + if hasSourceRange != (sourceSheetID != "") { + return fmt.Errorf("set-dropdown: source-range 与 source-sheet-id 必须同时指定") + } + if hasSourceRange { + if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil { + return fmt.Errorf("set-dropdown: %w", err) + } + } + return nil +} + // ── BuildXxxArgs: CLI flag → MCP param 转换函数 ────────────────────────────────── // 每个函数接收 CLI flag 名(kebab-case)的 map,输出 MCP 参数名(camelCase)的 map。 // 目前集中放在此文件;后续拆分命令文件时可移到各命令所在文件。 @@ -215,7 +247,15 @@ func BuildSetDropdownArgs(input map[string]any) map[string]any { args := map[string]any{ "sheetId": batchStr(input, "sheet-id"), "range": batchStr(input, "range"), - "options": input["options"], + } + if options, ok := input["options"]; ok && options != nil { + args["options"] = options + } + if sourceRange := batchStr(input, "source-range"); sourceRange != "" { + args["sourceRange"] = map[string]any{ + "sheetId": batchStr(input, "source-sheet-id"), + "a1Notation": sourceRange, + } } if v, ok := input["multi-select"]; ok { args["enableMultiSelect"] = v @@ -417,6 +457,8 @@ func newBatchUpdateCmd() *cobra.Command { toolName 使用 CLI 命令名(与原子命令一致),input 的键用 CLI flag 名去掉 --。 CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。 +source-range 的语义按 toolName 隔离:set-dropdown 中表示下拉候选项来源, +range fill/copy-to/move-to 中表示待填充、复制或移动的数据源区域。 支持的 CLI 命令名: range clear / range update / merge-cells / unmerge-cells / update-dimension @@ -425,6 +467,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。 set-dropdown / delete-dropdown / csv-put / delete-float-image 其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析, 前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。 +set-dropdown 不接受顶层 colors/source-colors;Inline 颜色应写在 options[].color, +SourceRange 颜色写入暂不支持。 注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立 dws sheet group-dimension 命令。 diff --git a/internal/helpers/sheet_batch_test.go b/internal/helpers/sheet_batch_test.go index f7038d08..edb85887 100644 --- a/internal/helpers/sheet_batch_test.go +++ b/internal/helpers/sheet_batch_test.go @@ -23,7 +23,15 @@ func TestCrossPlatformCoverageSheetBatchOperationTranslationCoversEveryMapping(t "group-state": "fold", } for name, mapping := range batchOpDispatch { - got, err := translateBatchOp(map[string]any{"toolName": name, "input": input}) + opInput := input + if name == "set-dropdown" { + opInput = make(map[string]any, len(input)) + for key, value := range input { + opInput[key] = value + } + delete(opInput, "source-range") + } + got, err := translateBatchOp(map[string]any{"toolName": name, "input": opInput}) if err != nil { t.Errorf("translateBatchOp(%q): %v", name, err) continue diff --git a/internal/helpers/sheet_data.go b/internal/helpers/sheet_data.go index 3cdb77b0..be6e9d2d 100644 --- a/internal/helpers/sheet_data.go +++ b/internal/helpers/sheet_data.go @@ -388,7 +388,10 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。 csv 带 [row=N] 行号前缀的 CSV 文本 colIndices 列字母映射数组(定位列用 colIndices[j],禁止手数逗号) rowIndices 行号映射数组 - hasMore 是否因 maxChars 截断 + hasMore 目标范围是否还有未返回数据 + truncationReasons 部分返回原因:max_cells / max_chars + resolvedRange 未传 --range 时底层解析出的完整目标范围 + returnedRange 本次实际完整返回的范围 取值模式(--value-render-option): formatted_value 格式化后的展示值(默认),如 ¥1,000.00、2025-06-01 @@ -398,9 +401,14 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。 与 range read 的区别: - CSV 格式 token 消耗约为 JSON 的 1/3 - 支持选择取值模式 - - 自动防爆(max-chars 截断 + has_more 标志) + - 自动防爆(30,000 单元格 / max-chars 上限 + hasMore 标志) - [row=N] 前缀防止行号计算错误 +当 hasMore=true 时本命令不会自动续读;请结合目标范围和 +returnedRange 从下一行显式传 --range。max_cells 不能通过增大 +--max-chars 解决。收到 forbidden.document.sizeOverLimit 表示工作簿整体 +无法装载,应创建更小副本或拆分工作簿,缩小 --range 不能解决。 + 注意:csv-get 不返回合并单元格结构。查看合并范围请使用 dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。`, Example: ` dws sheet csv-get --node NODE_ID diff --git a/internal/helpers/sheet_dimension.go b/internal/helpers/sheet_dimension.go index 4ce8d7ba..4afaf750 100644 --- a/internal/helpers/sheet_dimension.go +++ b/internal/helpers/sheet_dimension.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -18,6 +19,23 @@ func sizeTypeEnumHint(dimension string) string { return "pixel / standard" } +func validateDropdownSourceRangeInput(sourceSheetID, sourceRange string) error { + if strings.TrimSpace(sourceSheetID) == "" { + return fmt.Errorf("--source-sheet-id 不能为空") + } + sourceRange = strings.TrimSpace(sourceRange) + if sourceRange == "" { + return fmt.Errorf("--source-range 不能为空") + } + if strings.Contains(sourceRange, "!") { + return fmt.Errorf("--source-range 不能包含工作表前缀;请通过 --source-sheet-id 指定来源工作表") + } + if strings.HasPrefix(sourceRange, "=") || strings.Contains(sourceRange, ",") { + return fmt.Errorf("--source-range 必须是单一连续区域,不能是公式、表达式或多区域引用") + } + return nil +} + // 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 { @@ -775,15 +793,24 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 Short: "设置下拉列表", Long: `在钉钉表格的指定单元格范围内设置下拉列表。 -设置后,用户可以在这些单元格中从预定义的选项列表中选择值。 -支持自定义每个选项的颜色和是否允许多选。 +Inline 模式通过 --options 提供静态选项,支持选项颜色。 +SourceRange 模式通过 --source-sheet-id + --source-range 引用同一工作簿内的来源区域, +支持跨工作表、整行和整列引用;读取配置时不会展开来源区域的当前值或颜色。 +两种模式都支持 --multi-select,并且 --options 与 --source-range 必须且只能指定一个。 如果目标范围已存在下拉列表,会被新的配置覆盖。 nodeId 支持传入文档链接 URL 或文档 ID(dentryUuid),系统自动识别。 sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获取。 --options 为 JSON 数组,每个元素包含 value(必填)和 color(可选)。 -选项值不能包含英文逗号。`, +选项值不能包含英文逗号。 + +--source-range 只接受不含工作表前缀的 A1 区域,例如 T1:T3、T:T 或 1:3; +来源工作表由 --source-sheet-id 单独指定。SourceRange 颜色写入暂不支持。 + +已验证行为:工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 valid; +已验证的 move-dimension 场景会使引用变为 invalid。列删除、删除整个来源区域或来源工作表等未覆盖场景不能预设结果; +结构操作后应先回读 sourceRangeStatus,只有 invalid 时才重新选择来源并写入。`, Example: ` # 设置单选下拉列表 dws sheet set-dropdown --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" \ --options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]' @@ -793,32 +820,63 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ --multi-select + # 引用同一工作簿内另一工作表的区域作为候选项来源 + dws sheet set-dropdown --node NODE_ID --sheet-id TARGET_SHEET_ID --range "C2:C100" \ + --source-sheet-id SOURCE_SHEET_ID --source-range "T1:T3" + # 使用文档链接 URL dws sheet set-dropdown --node "https://alidocs.dingtalk.com/i/nodes/" \ --sheet-id SHEET_ID --range "C1:C10" --options '[{"value":"是"},{"value":"否"}]'`, RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { + return err + } optionsStr := mustGetFlag(cmd, "options") - var options []map[string]any - if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { - return fmt.Errorf("--options JSON 解析失败: %w", err) + sourceSheetID := mustGetFlag(cmd, "source-sheet-id") + sourceRange := mustGetFlag(cmd, "source-range") + hasOptions := optionsStr != "" + hasSourceRange := sourceRange != "" + if hasOptions == hasSourceRange { + return fmt.Errorf("--options 与 --source-range 必须且只能指定一个") } - if len(options) == 0 { - return fmt.Errorf("--options 至少包含 1 个选项") + if hasSourceRange && sourceSheetID == "" { + return fmt.Errorf("使用 --source-range 时必须同时指定 --source-sheet-id") } - for i, opt := range options { - val, ok := opt["value"].(string) - if !ok || val == "" { - return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i) - } - if strings.Contains(val, ",") { - return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val) - } + if !hasSourceRange && sourceSheetID != "" { + return fmt.Errorf("使用 --source-sheet-id 时必须同时指定 --source-range") } + toolArgs := map[string]any{ "nodeId": mustGetFlag(cmd, "node"), "sheetId": mustGetFlag(cmd, "sheet-id"), "range": mustGetFlag(cmd, "range"), - "options": options, + } + if hasOptions { + var options []map[string]any + if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { + return fmt.Errorf("--options JSON 解析失败: %w", err) + } + if len(options) == 0 { + return fmt.Errorf("--options 至少包含 1 个选项") + } + for i, opt := range options { + val, ok := opt["value"].(string) + if !ok || val == "" { + return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i) + } + if strings.Contains(val, ",") { + return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val) + } + } + toolArgs["options"] = options + } else { + if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil { + return err + } + toolArgs["sourceRange"] = map[string]any{ + "sheetId": sourceSheetID, + "a1Notation": sourceRange, + } } if multiSelect, _ := cmd.Flags().GetBool("multi-select"); multiSelect { toolArgs["enableMultiSelect"] = true @@ -839,36 +897,49 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 CLIPath: "sheet set-dropdown", PrimaryCLIPath: "sheet set-dropdown", }, - Description: "为指定范围设置下拉列表(可多选、可带颜色)。", + Description: "为指定范围设置 Inline 或 SourceRange 下拉列表(可多选;颜色仅 Inline 支持)。", Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_dropdown_lists"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "为指定范围设置下拉列表(可多选、可带颜色)。", + AgentSummary: "为指定范围设置 Inline 或 SourceRange 下拉列表;SourceRange 可跨同一工作簿内的工作表。", UseWhen: []string{"需要给单元格配置可选值下拉约束时"}, AvoidWhen: []string{"查看已有下拉用 get-dropdown;移除下拉用 delete-dropdown"}, - Examples: []string{"dws sheet set-dropdown --node --sheet-id --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'"}, + Examples: []string{ + "dws sheet set-dropdown --node --sheet-id --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'", + "dws sheet set-dropdown --node --sheet-id --range \"B2:B100\" --source-sheet-id --source-range \"T1:T3\"", + }, }, Parameters: []contract.ParamDecl{ {Name: "multi-select", Property: "enableMultiSelect"}, {Name: "node", Property: "nodeId"}, + {Name: "source-sheet-id", Property: "sourceRange.sheetId", Required: boolPtr(false), RequiredWhen: "--source-range is provided"}, + {Name: "source-range", Property: "sourceRange.a1Notation", Required: boolPtr(false), RequiredWhen: "exactly one of --options or --source-range must be provided"}, }, }, }) setDropdownCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") setDropdownCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") setDropdownCmd.Flags().String("range", "", "目标单元格范围,A1 表示法,如 A2:A100 (必填)") - setDropdownCmd.Flags().String("options", "", `下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'`) + setDropdownCmd.Flags().String("options", "", `Inline 下拉选项 JSON 数组,与 --source-range 二选一,如 '[{"value":"选项1","color":"#ff0000"}]'`) + setDropdownCmd.Flags().String("source-sheet-id", "", "SourceRange 来源工作表 ID;使用 --source-range 时必填") + setDropdownCmd.Flags().String("source-range", "", "SourceRange 来源区域,与 --options 二选一;不含工作表前缀,如 T1:T3、T:T 或 1:3") setDropdownCmd.Flags().Bool("multi-select", false, "是否允许多选(默认单选)") + cli.AnnotateRuntimeFlagFormat(setDropdownCmd, "source-range", "a1-range") + setDropdownCmd.MarkFlagsOneRequired("options", "source-range") + setDropdownCmd.MarkFlagsMutuallyExclusive("options", "source-range") + setDropdownCmd.MarkFlagsRequiredTogether("source-sheet-id", "source-range") getDropdownCmd := &cobra.Command{ Use: "get-dropdown", Short: "获取下拉列表配置", Long: `查询钉钉表格指定范围内的下拉列表配置。 -返回范围内所有单元格的下拉列表配置信息,包括选项值和颜色。 +Inline 配置返回 conditionValues 和 options;SourceRange 配置返回 +sourceType/sourceRangeStatus/enableMultiSelect,且仅在 sourceRangeStatus="valid" 时返回 +sourceRange。invalid 结果仍保留配置组,但省略 sourceRange;两种状态都不展开来源区域的候选值或颜色。 如果范围内存在多个不同的下拉列表配置,会分别返回每组配置及其覆盖的单元格列表。 如果范围内没有设置下拉列表,返回空。 diff --git a/internal/helpers/sheet_dropdown_source_range_test.go b/internal/helpers/sheet_dropdown_source_range_test.go new file mode 100644 index 00000000..086b7da6 --- /dev/null +++ b/internal/helpers/sheet_dropdown_source_range_test.go @@ -0,0 +1,248 @@ +package helpers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" +) + +func TestCrossPlatformCoverageSetDropdownSourceRangeCommand(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}} + installScriptedCaller(t, caller) + err := executeDimensionCoverage(t, "set-dropdown", + "--node", "node-1", + "--sheet-id", "target-sheet", + "--range", "B2:B100", + "--source-sheet-id", "source-sheet", + "--source-range", "$t$1:$t$3", + "--multi-select", + ) + if err != nil { + t.Fatalf("set SourceRange dropdown: %v", err) + } + if caller.tool != "set_dropdown_lists" { + t.Fatalf("tool = %q, want set_dropdown_lists", caller.tool) + } + if _, exists := caller.args["options"]; exists { + t.Fatalf("SourceRange request must omit options: %#v", caller.args) + } + source, ok := caller.args["sourceRange"].(map[string]any) + if !ok { + t.Fatalf("sourceRange = %#v", caller.args["sourceRange"]) + } + if source["sheetId"] != "source-sheet" || source["a1Notation"] != "$t$1:$t$3" { + t.Fatalf("sourceRange = %#v", source) + } + if caller.args["enableMultiSelect"] != true { + t.Fatalf("enableMultiSelect = %#v", caller.args["enableMultiSelect"]) + } +} + +func TestCrossPlatformCoverageSetDropdownInlineCommandStillOmitsSourceRange(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}} + installScriptedCaller(t, caller) + err := executeDimensionCoverage(t, "set-dropdown", + "--node", "node-1", + "--sheet-id", "sheet-1", + "--range", "A1:A3", + "--options", `[{"value":"one","color":"#ff0000"}]`, + ) + if err != nil { + t.Fatalf("set inline dropdown: %v", err) + } + if _, exists := caller.args["sourceRange"]; exists { + t.Fatalf("inline request must omit sourceRange: %#v", caller.args) + } + options, ok := caller.args["options"].([]map[string]any) + if !ok || len(options) != 1 || options[0]["value"] != "one" { + t.Fatalf("options = %#v", caller.args["options"]) + } +} + +func TestCrossPlatformCoverageSetDropdownModeConstraints(t *testing.T) { + base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"} + tests := []struct { + name string + args []string + want string + }{ + {name: "neither", want: "one of the flags"}, + {name: "both", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source", "--source-range", "A1:A3"}, want: "none of the others"}, + {name: "source sheet without range", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source"}, want: "must all be set"}, + {name: "range without source sheet", args: []string{"--source-range", "A1:A3"}, want: "must all be set"}, + {name: "sheet prefix", args: []string{"--source-sheet-id", "source", "--source-range", "Sheet2!A1:A3"}, want: "不能包含工作表前缀"}, + {name: "formula", args: []string{"--source-sheet-id", "source", "--source-range", "=A1:A3"}, want: "必须是单一连续区域"}, + {name: "multi region", args: []string{"--source-sheet-id", "source", "--source-range", "A1:A3,C1:C3"}, want: "必须是单一连续区域"}, + {name: "blank source range", args: []string{"--source-sheet-id", "source", "--source-range", " \t "}, want: "--source-range 不能为空"}, + {name: "blank source sheet", args: []string{"--source-sheet-id", " \t ", "--source-range", "A1:A3"}, want: "--source-sheet-id 不能为空"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + installScriptedCaller(t, caller) + args := append(append([]string{}, base...), tc.args...) + err := executeDimensionCoverage(t, "set-dropdown", args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("calls = %d, validation must fail before request", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageSetDropdownRunEValidation(t *testing.T) { + tests := []struct { + name string + options string + source string + rangeID string + want string + }{ + {name: "neither mode", want: "必须且只能指定一个"}, + {name: "source range without sheet", rangeID: "A1:A3", want: "必须同时指定 --source-sheet-id"}, + {name: "source sheet without range", options: `[{"value":"one"}]`, source: "source", want: "必须同时指定 --source-range"}, + {name: "invalid options JSON", options: "{", want: "JSON 解析失败"}, + {name: "missing option value", options: `[{}]`, want: "value 为空"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cmd := dimensionCoverageCommand(t, "set-dropdown") + for name, value := range map[string]string{ + "node": "node", "sheet-id": "sheet", "range": "A1", + "options": tc.options, "source-sheet-id": tc.source, "source-range": tc.rangeID, + } { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + err := cmd.RunE(cmd, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageSetDropdownPreservesSchemaConstraints(t *testing.T) { + cmd := dimensionCoverageCommand(t, "set-dropdown") + if constraints := runtimeannotate.CommandConstraints(cmd); !runtimeannotate.ConstraintsEmpty(constraints) { + t.Fatalf("constraints = %#v, want no new schema-level constraints", constraints) + } +} + +func TestCrossPlatformCoverageBatchSetDropdownSourceRange(t *testing.T) { + got, err := translateBatchOp(map[string]any{ + "toolName": "set-dropdown", + "input": map[string]any{ + "sheet-id": "target-sheet", + "range": "B2:B100", + "source-sheet-id": "source-sheet", + "source-range": "T:T", + "multi-select": true, + }, + }) + if err != nil { + t.Fatalf("translate SourceRange batch op: %v", err) + } + input := got["input"].(map[string]any) + if _, exists := input["options"]; exists { + t.Fatalf("SourceRange batch input must omit options: %#v", input) + } + source := input["sourceRange"].(map[string]any) + if source["sheetId"] != "source-sheet" || source["a1Notation"] != "T:T" { + t.Fatalf("sourceRange = %#v", source) + } + + invalid := []map[string]any{ + {}, + {"options": []any{map[string]any{"value": "one"}}, "source-sheet-id": "source", "source-range": "A1:A3"}, + {"source-range": "A1:A3"}, + {"source-sheet-id": "source", "source-range": "A1:A3", "colors": []any{"#fff"}}, + {"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}}, + {"source-sheet-id": "source", "source-range": "Sheet2!A1:A3"}, + } + for _, value := range invalid { + if _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": value}); err == nil { + t.Errorf("invalid batch SourceRange input %#v returned nil", value) + } + } +} + +func TestCrossPlatformCoverageBatchSetDropdownValidationGuidance(t *testing.T) { + tests := []struct { + name string + input map[string]any + want string + }{ + { + name: "inline top-level colors", + input: map[string]any{"options": []any{map[string]any{"value": "one"}}, "colors": []any{"#fff"}}, + want: "Inline 颜色请写入 options[].color", + }, + { + name: "source top-level colors", + input: map[string]any{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}}, + want: "SourceRange 颜色写入暂不支持", + }, + { + name: "blank source range", + input: map[string]any{"source-sheet-id": "source", "source-range": " \t "}, + want: "--source-range 不能为空", + }, + { + name: "blank source sheet", + input: map[string]any{"source-sheet-id": " \t ", "source-range": "A1:A3"}, + want: "--source-sheet-id 不能为空", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": tc.input}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageDropdownHelpDescribesDynamicAndInvalidContracts(t *testing.T) { + setDropdown := dimensionCoverageCommand(t, "set-dropdown") + if !strings.Contains(setDropdown.Long, "会自动调整引用并保持 valid") || + !strings.Contains(setDropdown.Long, "只有 invalid 时才重新选择来源并写入") { + t.Fatalf("set-dropdown help does not describe verified structural behavior:\n%s", setDropdown.Long) + } + getDropdown := dimensionCoverageCommand(t, "get-dropdown") + if !strings.Contains(getDropdown.Long, `仅在 sourceRangeStatus="valid" 时返回`) || + !strings.Contains(getDropdown.Long, "invalid 结果仍保留配置组,但省略 sourceRange") { + t.Fatalf("get-dropdown help does not describe conditional sourceRange readback:\n%s", getDropdown.Long) + } +} + +func TestCrossPlatformCoverageValidateSourceRangeDataValidation(t *testing.T) { + valid := []any{ + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "missing-sheet", "a1Notation": "not-validated-locally"}, "enableMultiSelect": true}, + } + for _, value := range valid { + if err := validateDataValidation(value, "dv"); err != nil { + t.Errorf("valid SourceRange data validation %#v: %v", value, err) + } + } + + invalid := []any{ + map[string]any{"type": "dropdown", "options": []any{map[string]any{"value": "one"}}, "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}}, + map[string]any{"type": "dropdown", "sourceRange": "T1:T3"}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"a1Notation": "T1:T3"}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source"}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3", "colors": []any{"#fff"}}}, + map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}, "enableMultiSelect": "yes"}, + } + for _, value := range invalid { + if err := validateDataValidation(value, "dv"); err == nil { + t.Errorf("invalid SourceRange data validation %#v returned nil", value) + } + } +} diff --git a/internal/helpers/sheet_range_ops.go b/internal/helpers/sheet_range_ops.go index 5d6c8217..1c3a8fb5 100644 --- a/internal/helpers/sheet_range_ops.go +++ b/internal/helpers/sheet_range_ops.go @@ -26,6 +26,16 @@ func newRangeCmd() *cobra.Command { value 单元格值(内容由 --value-render-option 决定) dataValidation 数据验证配置(下拉列表/复选框),无则为 null hyperlink 单元格级超链接(path/sheet/range),无则省略 +顶层还会返回 rowIndices / colIndices 与完成度字段: + hasMore 目标范围是否还有未返回数据 + truncationReasons 部分返回原因(max_cells) + resolvedRange 未传 --range 时底层解析出的完整目标范围 + returnedRange 本次实际完整返回的范围 + +单次最多返回 30,000 个单元格。hasMore=true 是部分成功,本命令 +不会自动续读;请结合目标范围和 returnedRange 从下一行显式传 +--range。收到 forbidden.document.sizeOverLimit 则表示工作簿整体无法装载, +应创建更小副本或拆分工作簿,缩小 --range 不能解决。 注意:range read/get 不返回合并单元格结构。查看合并范围请使用 dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。 @@ -157,7 +167,12 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg 2) {"dataValidation":{"type":"none"}} → 显式清除该单元格 DV 3) {"dataValidation":{"type":"dropdown",...}} → 写新 dropdown(覆盖) {"dataValidation":{"type":"checkbox",...}} → 写新 checkbox(覆盖) - dropdown: {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}} + Inline dropdown: + {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}} + SourceRange dropdown(同一工作簿内可跨工作表;不展开来源 values,不支持 colors): + {"dataValidation":{"type":"dropdown","sourceRange":{"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"},"enableMultiSelect":false}} + dropdown 的 options 与 sourceRange 必须且只能传一个。SourceRange 支持普通区域、整行和整列; + a1Notation 不带工作表前缀,来源工作表单独写在 sheetId。 checkbox: {"dataValidation":{"type":"checkbox","checked":true}} 可与 text/richText 共存,也可单独使用(如 {dataValidation:{type:"none"}} 仅清除 DV 不写值) @@ -169,6 +184,8 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg - 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles - 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并 - csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式 + - SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;move-dimension 及其他未覆盖的删除/移动场景后先回读 sourceRangeStatus,仅 invalid 时重新选源写入 + - 同一 cell 的 value/style 已写入但 SourceRange 校验失败时,服务端可返回 success=true,并通过 message 明确下拉未创建;必须检查 message,必要时重新读取确认 - 清空整片区域请用 dws sheet range clear`, Example: ` # 写入文本 dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \ diff --git a/internal/helpers/sheet_read_completion_test.go b/internal/helpers/sheet_read_completion_test.go new file mode 100644 index 00000000..dca4bfdc --- /dev/null +++ b/internal/helpers/sheet_read_completion_test.go @@ -0,0 +1,202 @@ +package helpers + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" +) + +func executeSheetReadCompletionCommand(t *testing.T, caller *scriptedToolCaller, args ...string) (*bytes.Buffer, error) { + t.Helper() + installScriptedCaller(t, caller) + installSheetProductArgs(t) + + stdout := &bytes.Buffer{} + deps.Out.w = stdout + + cmd := newSheetCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(args) + return stdout, cmd.Execute() +} + +func decodeSheetReadOutput(t *testing.T, stdout *bytes.Buffer) map[string]any { + t.Helper() + var got map[string]any + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode sheet read output %q: %v", stdout.String(), err) + } + return got +} + +func assertSheetReadCompletionMetadata(t *testing.T, got map[string]any) { + t.Helper() + if hasMore, ok := got["hasMore"].(bool); !ok || !hasMore { + t.Fatalf("hasMore = %#v, want true", got["hasMore"]) + } + if got["resolvedRange"] != "A1:A30001" { + t.Fatalf("resolvedRange = %#v", got["resolvedRange"]) + } + if got["returnedRange"] != "A1:A30000" { + t.Fatalf("returnedRange = %#v", got["returnedRange"]) + } + reasons, ok := got["truncationReasons"].([]any) + if !ok || len(reasons) == 0 || reasons[0] != "max_cells" { + t.Fatalf("truncationReasons = %#v", got["truncationReasons"]) + } +} + +// csv-get intentionally projects the MCP response rather than inventing a +// client-side page model. Keep all completion metadata visible and make one +// request only, even when the server reports a partial success. +func TestSheetCSVGetPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) { + const response = `{"csv":"[row=1]1\n","colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells","max_chars"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}` + caller := &scriptedToolCaller{ + format: "raw", + steps: []scriptedToolStep{{text: response}}, + } + + stdout, err := executeSheetReadCompletionCommand(t, caller, + "csv-get", "--node", "NODE") + if err != nil { + t.Fatalf("csv-get: %v", err) + } + if caller.calls != 1 || caller.tool != "get_range_as_csv" { + t.Fatalf("calls/tool = %d/%q, want one get_range_as_csv call", caller.calls, caller.tool) + } + if _, exists := caller.args["range"]; exists { + t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"]) + } + if got := strings.TrimSpace(stdout.String()); got != response { + t.Fatalf("raw csv-get response changed:\n got: %s\nwant: %s", got, response) + } + + got := decodeSheetReadOutput(t, stdout) + assertSheetReadCompletionMetadata(t, got) + reasons := got["truncationReasons"].([]any) + if len(reasons) != 2 || reasons[1] != "max_chars" { + t.Fatalf("truncationReasons = %#v, want max_cells and max_chars", reasons) + } +} + +// range read parses the get_cell_infos envelope to remove empty per-cell +// schema shells. That cleanup must not discard new top-level completion fields. +func TestSheetRangeReadPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) { + const response = `{"cells":[[{"value":"1","dataValidation":{"type":null},"hyperlink":{"url":null}}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}` + caller := &scriptedToolCaller{ + format: "json", + steps: []scriptedToolStep{{text: response}}, + } + + stdout, err := executeSheetReadCompletionCommand(t, caller, + "range", "read", "--node", "NODE") + if err != nil { + t.Fatalf("range read: %v", err) + } + if caller.calls != 1 || caller.tool != "get_cell_infos" { + t.Fatalf("calls/tool = %d/%q, want one get_cell_infos call", caller.calls, caller.tool) + } + if _, exists := caller.args["range"]; exists { + t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"]) + } + + got := decodeSheetReadOutput(t, stdout) + assertSheetReadCompletionMetadata(t, got) + cells := got["cells"].([]any) + row := cells[0].([]any) + cell := row[0].(map[string]any) + if _, exists := cell["dataValidation"]; exists { + t.Fatalf("empty dataValidation shell was not removed: %#v", cell) + } + if _, exists := cell["hyperlink"]; exists { + t.Fatalf("empty hyperlink shell was not removed: %#v", cell) + } +} + +// sheet +read calls get_cell_infos through this direct passthrough instead of +// callMCPToolCellInfos. Lock that third read surface to the same raw envelope +// contract and to one request only. +func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T) { + const response = `{"cells":[[{"value":"1"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}` + caller := &scriptedToolCaller{ + format: "raw", + steps: []scriptedToolStep{{text: response}}, + } + installScriptedCaller(t, caller) + stdout := &bytes.Buffer{} + deps.Out.w = stdout + + if err := CallMCPToolOnServer("sheet", "get_cell_infos", map[string]any{"nodeId": "NODE"}); err != nil { + t.Fatalf("direct get_cell_infos: %v", err) + } + if caller.calls != 1 || caller.server != "sheet" || caller.tool != "get_cell_infos" { + t.Fatalf("calls/server/tool = %d/%q/%q", caller.calls, caller.server, caller.tool) + } + if got := strings.TrimSpace(stdout.String()); got != response { + t.Fatalf("raw get_cell_infos response changed:\n got: %s\nwant: %s", got, response) + } + assertSheetReadCompletionMetadata(t, decodeSheetReadOutput(t, stdout)) +} + +// Large-CP failure is not a pageable success. Both the raw csv-get path and +// both get_cell_infos paths must retain the backend code and safe user message +// exactly, matching the generic get_all_sheets error-classification path. +func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) { + const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}` + tests := []struct { + name string + args []string + tool string + direct bool + }{ + {name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"}, + {name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"}, + {name: "cell-infos-direct", tool: "get_cell_infos", direct: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{ + format: "json", + steps: []scriptedToolStep{{text: response}}, + } + var stdout *bytes.Buffer + var err error + if test.direct { + installScriptedCaller(t, caller) + stdout = &bytes.Buffer{} + deps.Out.w = stdout + err = CallMCPToolOnServer("sheet", test.tool, map[string]any{"nodeId": "NODE"}) + } else { + stdout, err = executeSheetReadCompletionCommand(t, caller, test.args...) + } + if err == nil { + t.Fatal("large-CP response was accepted as success") + } + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError { + t.Fatalf("error = %T %v, want MCP tool error", err, err) + } + if cliErr.Message != response { + t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response) + } + for _, want := range []string{ + "forbidden.document.sizeOverLimit", + "The workbook data is too large to process.", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not preserve %q", err, want) + } + } + if stdout.Len() != 0 { + t.Fatalf("failure wrote a success body: %q", stdout.String()) + } + if caller.calls != 1 || caller.tool != test.tool { + t.Fatalf("calls/tool = %d/%q, want one %s call", caller.calls, caller.tool, test.tool) + } + }) + } +} diff --git a/internal/helpers/sheet_validate.go b/internal/helpers/sheet_validate.go index e40a7cb3..f32e6836 100644 --- a/internal/helpers/sheet_validate.go +++ b/internal/helpers/sheet_validate.go @@ -239,22 +239,48 @@ func validateDataValidation(dvRaw any, path string) error { } switch dvType { case "dropdown": - optionsRaw, ok := dv["options"] - if !ok { - return fmt.Errorf("%s: type=dropdown 必须包含 options 数组", path) + optionsRaw, hasOptions := dv["options"] + sourceRangeRaw, hasSourceRange := dv["sourceRange"] + hasOptions = hasOptions && optionsRaw != nil + hasSourceRange = hasSourceRange && sourceRangeRaw != nil + if hasOptions == hasSourceRange { + return fmt.Errorf("%s: type=dropdown 的 options 与 sourceRange 必须且只能包含一个", path) } - options, ok := optionsRaw.([]any) - if !ok || len(options) == 0 { - return fmt.Errorf("%s.options 必须为非空数组", path) - } - for i, opt := range options { - optMap, ok := opt.(map[string]any) - if !ok { - return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i) + if hasOptions { + options, ok := optionsRaw.([]any) + if !ok || len(options) == 0 { + return fmt.Errorf("%s.options 必须为非空数组", path) } - val, ok := optMap["value"].(string) - if !ok || val == "" { - return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i) + for i, opt := range options { + optMap, ok := opt.(map[string]any) + if !ok { + return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i) + } + val, ok := optMap["value"].(string) + if !ok || val == "" { + return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i) + } + } + } else { + sourceRange, ok := sourceRangeRaw.(map[string]any) + if !ok { + return fmt.Errorf("%s.sourceRange 必须为 object", path) + } + if _, exists := sourceRange["colors"]; exists { + return fmt.Errorf("%s.sourceRange.colors 暂不支持", path) + } + sheetID, ok := sourceRange["sheetId"].(string) + if !ok || strings.TrimSpace(sheetID) == "" { + return fmt.Errorf("%s.sourceRange.sheetId 必须为非空字符串", path) + } + a1Notation, ok := sourceRange["a1Notation"].(string) + if !ok || strings.TrimSpace(a1Notation) == "" { + return fmt.Errorf("%s.sourceRange.a1Notation 必须为非空字符串", path) + } + } + if multi, exists := dv["enableMultiSelect"]; exists { + if _, ok := multi.(bool); !ok { + return fmt.Errorf("%s.enableMultiSelect 必须为 boolean", path) } } case "checkbox": diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh index 9922e9ab..3f586dd0 100755 --- a/scripts/ci/run-app-race-tests.sh +++ b/scripts/ci/run-app-race-tests.sh @@ -6,16 +6,35 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" usage() { printf '%s\n' \ "usage: $0 verify " \ - " $0 run " >&2 + " $0 run [partition]" \ + " $0 list-partitions" >&2 exit 2 } -[ "$#" -eq 2 ] || usage -mode="$1" -app_package="$2" +# Single source of truth for the partition set. CI runs one job per partition and +# pins its shard names to this list, so a name that appears here without a +# dispatch entry below fails closed rather than silently skipping tests. +APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz' +mode="${1:-}" +partition="" case "$mode" in - verify|run) ;; + list-partitions) + [ "$#" -eq 1 ] || usage + for name in $APP_PARTITIONS; do + printf '%s\n' "$name" + done + exit 0 + ;; + verify) + [ "$#" -eq 2 ] || usage + app_package="$2" + ;; + run) + [ "$#" -eq 2 ] || [ "$#" -eq 3 ] || usage + app_package="$2" + partition="${3:-}" + ;; *) usage ;; esac @@ -70,21 +89,49 @@ if [ "$unmatched_count" -ne 0 ]; then exit 1 fi -for partition in \ +# The loop variable is deliberately not named "partition": that name holds the +# partition requested on the command line, and run mode still executes this +# discovery pass before dispatching. +classified='' +for spec in \ "schema:$schema_count" \ "a-b:$ab_count" \ "c:$c_count" \ "d-r:$dr_count" \ "s-z-example-fuzz:$sz_count" do - name="${partition%%:*}" - count="${partition#*:}" + name="${spec%%:*}" + count="${spec#*:}" + classified="$classified $name" if [ "$count" -eq 0 ]; then printf 'app race partition %s is empty\n' "$name" >&2 exit 1 fi done +# The counters above and APP_PARTITIONS must describe the same set in both +# directions. A counted partition missing from APP_PARTITIONS would never be +# dispatched by any CI job, and a dispatchable partition with no counter would +# escape the exact-coverage check above. +for name in $APP_PARTITIONS; do + case " $classified " in + *" $name "*) ;; + *) + printf 'app partition %s has no coverage counter\n' "$name" >&2 + exit 1 + ;; + esac +done +for name in $classified; do + case " $APP_PARTITIONS " in + *" $name "*) ;; + *) + printf 'counted partition %s is not a dispatchable app partition\n' "$name" >&2 + exit 1 + ;; + esac +done + total_count="$(wc -l < "$tests" | tr -d ' ')" assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count)) if [ "$assigned_count" -ne "$total_count" ]; then @@ -101,26 +148,73 @@ fi run_partition() { name="$1" - run_pattern="$2" - skip_pattern="${3:-}" + instrumentation="$2" + run_pattern="$3" + skip_pattern="${4:-}" - printf 'running internal/app race partition %s\n' "$name" + # Fail closed on an unrecognized mode: a typo must not silently drop race + # instrumentation from a partition that is supposed to carry it. + case "$instrumentation" in + race|no-race) ;; + *) + printf 'unknown instrumentation %s for app partition %s\n' \ + "$instrumentation" "$name" >&2 + exit 1 + ;; + esac + + printf 'running internal/app %s partition %s\n' "$instrumentation" "$name" + set -- -v -count=1 -timeout=15m -run "$run_pattern" if [ -n "$skip_pattern" ]; then - go test -v -race -count=1 -timeout=15m \ - -run "$run_pattern" -skip "$skip_pattern" "$app_package" - else - go test -v -race -count=1 -timeout=15m \ - -run "$run_pattern" "$app_package" + set -- "$@" -skip "$skip_pattern" fi + if [ "$instrumentation" = race ]; then + set -- -race "$@" + fi + go test "$@" "$app_package" } # Schema assembly has the largest transient memory footprint. Run those tests # in a fresh process, then keep each remaining name range in its own process so # command trees retained by process-global registries are released between # partitions. The complementary run/skip patterns preserve the full test set. +# +# The schema partition runs uninstrumented. Its tests assert structural +# Schema-to-Cobra contracts over a single goroutine: none of them call +# t.Parallel or start a goroutine, so the race detector has no concurrent access +# to observe here. The process-global lazy metadata that does need race coverage +# (schema_source_root's atomic.Value, the parameter-binding lazy loaders) is +# exercised by internal/cli's concurrent tests, which stay instrumented. The +# instrumentation is not free on this partition: its shared sync.Once Catalog +# build is allocation-heavy, and -race made the partition roughly 11x slower +# (26s -> 291s locally, 357s in CI) without being able to report anything. schema_pattern='^Test.*Schema' -run_partition schema "$schema_pattern" -run_partition a-b '^Test[A-B]' "$schema_pattern" -run_partition c '^TestC' "$schema_pattern" -run_partition d-r '^Test[D-R]' "$schema_pattern" -run_partition s-z-example-fuzz '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" + +# Dispatch table for the partition set declared in APP_PARTITIONS. CI passes one +# partition per job so they run concurrently; running without a partition keeps +# the original end-to-end behaviour for local use and for any caller that wants +# the whole package in one invocation. +run_named_partition() { + case "$1" in + schema) run_partition schema no-race "$schema_pattern" ;; + a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;; + c) run_partition c race '^TestC' "$schema_pattern" ;; + d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;; + s-z-example-fuzz) + run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" + ;; + *) + printf 'unknown app partition: %s\n' "$1" >&2 + exit 1 + ;; + esac +} + +if [ -n "$partition" ]; then + run_named_partition "$partition" + exit 0 +fi + +for name in $APP_PARTITIONS; do + run_named_partition "$name" +done diff --git a/skills/mono/references/products/sheet/sheet-batch-operations.md b/skills/mono/references/products/sheet/sheet-batch-operations.md index 8d922bec..66fafc90 100644 --- a/skills/mono/references/products/sheet/sheet-batch-operations.md +++ b/skills/mono/references/products/sheet/sheet-batch-operations.md @@ -83,7 +83,8 @@ Example: {"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}}, - {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}} + {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}, + {"toolName":"set-dropdown","input":{"sheet-id":"Sheet1","range":"C2:C100","source-sheet-id":"SourceSheet","source-range":"T1:T3"}} ]' dws sheet batch-update --node --continue-on-error --operations '[...]' Flags: @@ -101,6 +102,9 @@ Notes: - operations 最多 20 条 - 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁) - `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本 + - `set-dropdown` 的 `input` 中,Inline 使用 `options`;SourceRange 使用 `source-sheet-id` + `source-range`,两种模式必须且只能选一个。顶层 `colors` / `source-colors` 会被拒绝;Inline 颜色写在 `options[].color`,SourceRange 颜色写入暂不支持 + - `set-dropdown` SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入 + - `source-range` 按 `toolName` 解释:`set-dropdown` 中是下拉候选项来源;`range fill` / `range copy-to` / `range move-to` 中是待填充、复制或移动的数据源区域 - 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽 - `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension` - `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put` diff --git a/skills/mono/references/products/sheet/sheet-dropdown.md b/skills/mono/references/products/sheet/sheet-dropdown.md index 0b135aa4..05b5f0c0 100644 --- a/skills/mono/references/products/sheet/sheet-dropdown.md +++ b/skills/mono/references/products/sheet/sheet-dropdown.md @@ -7,6 +7,7 @@ 用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉": - 设置下拉列表 → `set-dropdown` - 设置多选下拉 → `set-dropdown --multi-select` +- 引用单元格区域作为候选项 → `set-dropdown --source-sheet-id ... --source-range ...` 用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项": - 获取下拉列表配置 → `get-dropdown` @@ -29,18 +30,25 @@ Example: dws sheet set-dropdown --node --sheet-id --range "B2:B50" \ --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ --multi-select + + # 引用同一工作簿内另一工作表的区域作为候选项来源 + dws sheet set-dropdown --node --sheet-id --range "C2:C100" \ + --source-sheet-id --source-range "T1:T3" Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) - --options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]' - --multi-select 是否允许多选(默认单选) + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) + --options string Inline 下拉选项 JSON 数组,与 --source-range 二选一 + --source-sheet-id string SourceRange 来源工作表 ID,与 --source-range 同时指定 + --source-range string SourceRange 来源区域,与 --options 二选一;不带工作表前缀 + --multi-select 是否允许多选(默认单选) ``` -在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。 -- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。 +在指定单元格范围内设置下拉列表。Inline 模式直接存储选项;SourceRange 模式引用同一工作簿内的来源区域,可跨工作表,并支持普通区域、整行和整列。 +- **用途**:为单元格配置静态选项或区域来源下拉,两种模式都支持多选;颜色仅 Inline 支持。 - **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。 -- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。 +- **注意**:`--options` 与 `--source-range` 必须且只能指定一个。`--source-range` 只写 `T1:T3`、`T:T`、`1:3` 这类 A1 区域,来源工作表通过 `--source-sheet-id` 单独指定;不接受工作表前缀、公式或多区域。SourceRange 颜色写入暂不支持。 +- **结构操作行为**:已验证的工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 `valid`;已验证的 `move-dimension` 场景会使其变为 `invalid`。列删除、删除整个来源区域或来源工作表等场景未覆盖,不能预设结果;结构操作后先回读 `sourceRangeStatus`,仅在 `invalid` 时重新选择来源并写入。 ### 获取下拉列表配置 ``` @@ -55,10 +63,10 @@ Flags: --range string 查询范围,A1 表示法,如 A1:A100 (必填) ``` -查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。 +查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -81,14 +89,15 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | -| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | +| `set-dropdown` | `range` 实际设置范围、`enableMultiSelect` 是否多选;仅 Inline 模式返回 `optionCount` | 确认下拉列表设置成功 | +| `get-dropdown` | `hasDropdown`、`dataValidations`;按 `sourceType` 区分 Inline 与 SourceRange | 查看已有下拉配置 | | `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 - ★ **`--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 +- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 +- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 +- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md index 0e2ae054..f8008d0e 100644 --- a/skills/mono/references/products/sheet/sheet-read-data.md +++ b/skills/mono/references/products/sheet/sheet-read-data.md @@ -13,7 +13,7 @@ | 读取目的 | 推荐命令 | 说明 | |---------|---------|------| -| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 | +| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 30,000 单元格和 maxChars 防爆 | | 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 | | 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation | | 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) | @@ -52,7 +52,10 @@ Flags: - `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算 - `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号 - `rowIndices` — 行号映射数组(如 `[1,2,3]`) -- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取 +- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 +- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -63,10 +66,15 @@ Flags: | `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 | | `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 | -**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批: -- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 -- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` …… -- 单次建议 ≤5000 单元格 +**大表分批读取**:`csv-get` 不会自动发起后续请求。当 `hasMore=true` 时: +- 显式传了 `--range` 时,该范围是完整目标;未传时,以 `resolvedRange` 为完整目标 +- 根据 `returnedRange` 的结束行,从下一行开始显式构造新的 `--range`;例如目标是 `A1:A30001`、本次返回 `A1:A30000`,则继续读 `A30001:A30001` +- `max_cells` 不能通过增大 `--max-chars` 解决;`max_chars` 且没有 `returnedRange` 时,增大 `--max-chars` 或缩小列范围 +- 上述完成度协议覆盖未传 `--range` 和显式有限矩形范围;`A:A`、`1:1` 等非有限范围可能不返回完成度字段,建议先用 `sheet info` 的 `nonEmptyRange` 换算成有限范围再读 +- 即使未命中硬上限,单次仍建议控制在 5000 单元格以内,减少超时和 token 消耗 + +**工作簿过大失败与部分成功不同**:收到顶层 +`errorCode=forbidden.document.sizeOverLimit` 和对应 `errorMessage` 时,表示工作簿整体无法装载。应停止读取并提示创建更小副本或拆分工作簿;缩小 `--range` 不能解决这类错误。不要把它当成 `hasMore=true` 的可续读结果。 ### 以 table/dataframe 协议读取结构化数据 ``` @@ -130,6 +138,8 @@ Flags: ``` **返回字段说明**: +- `rowIndices` / `colIndices` — 本次返回的真实行号与列字母映射 +- `hasMore` / `truncationReasons` / `resolvedRange` / `returnedRange` — 语义与 `csv-get` 相同;`range read` 的 `truncationReasons` 主要为 `max_cells` - `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下: | 字段 | 类型 | 是否必有 | 说明 | @@ -146,10 +156,16 @@ Flags: | type | 字段 | 说明 | |------|------|------| -| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 | -| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` Inline | `sourceType: "inline"` | 静态选项模式 | +| `dropdown` Inline | `options: [{value: string, color?: string}]` | 下拉选项列表 | +| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | +| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | +SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 + **hyperlink 结构**: | type | 字段 | 说明 | @@ -216,7 +232,7 @@ Flags: **公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。 -**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: +**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分批读取策略,每次都必须检查 `hasMore`: - 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 - 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… - 避免不传 `--range` 直接读取整个大工作表 @@ -232,7 +248,7 @@ dws sheet list --node --format json # 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等) dws sheet info --node --sheet-id --format json -# 3. 读取全部数据 +# 3. 读取数据(只有 hasMore=false 才代表目标范围已完整返回) dws sheet range read --node --sheet-id --format json # 4. 读取指定区域 @@ -251,6 +267,8 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 - `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 - `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) +- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 +- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 diff --git a/skills/mono/references/products/sheet/sheet-write-data.md b/skills/mono/references/products/sheet/sheet-write-data.md index eaa5e695..9050fb43 100644 --- a/skills/mono/references/products/sheet/sheet-write-data.md +++ b/skills/mono/references/products/sheet/sheet-write-data.md @@ -386,9 +386,14 @@ Flags: **dropdown(下拉列表)**: ```json { "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } } +{ "dataValidation": { "type": "dropdown", "sourceRange": {"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"}, "enableMultiSelect": false } } ``` -- `options`:必填,`[{value, color?}]` 数组 +- `options` 与 `sourceRange` 必须且只能提供一个 +- Inline:`options` 为非空 `[{value, color?}]` 数组 +- SourceRange:`sourceRange` 为 `{sheetId,a1Notation}`;可引用同一工作簿内其他工作表,支持普通区域、整行和整列。`a1Notation` 不带工作表前缀,不接受公式或多区域;颜色写入暂不支持 - `enableMultiSelect`:可选,是否多选,默认 false +- SourceRange 在已验证的工作表重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读 `sourceRangeStatus`,仅 `invalid` 时重新选源写入 +- 复合 cell 写入中,如果值或样式已成功落盘但 SourceRange 校验失败,服务端可返回 `success:true` 并通过 `message` 说明下拉未创建;必须检查 `message` 并按需回读 **checkbox(复选框)**: ```json 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 8d922bec..66fafc90 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-batch-operations.md @@ -83,7 +83,8 @@ Example: {"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}}, - {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}} + {"toolName":"group-dimension","input":{"sheet-id":"Sheet1","range":"3:7","group-state":"expand"}}, + {"toolName":"set-dropdown","input":{"sheet-id":"Sheet1","range":"C2:C100","source-sheet-id":"SourceSheet","source-range":"T1:T3"}} ]' dws sheet batch-update --node --continue-on-error --operations '[...]' Flags: @@ -101,6 +102,9 @@ Notes: - operations 最多 20 条 - 当需要对多个区域执行相同清除时,优先使用 `range batch-clear`(更简洁) - `csv-put` 子操作与独立命令语义一致:CSV 字段值以 `=` 开头时按公式解析;前加单引号时写入以 `=` 开头的字面文本 + - `set-dropdown` 的 `input` 中,Inline 使用 `options`;SourceRange 使用 `source-sheet-id` + `source-range`,两种模式必须且只能选一个。顶层 `colors` / `source-colors` 会被拒绝;Inline 颜色写在 `options[].color`,SourceRange 颜色写入暂不支持 + - `set-dropdown` SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入 + - `source-range` 按 `toolName` 解释:`set-dropdown` 中是下拉候选项来源;`range fill` / `range copy-to` / `range move-to` 中是待填充、复制或移动的数据源区域 - 典型场景:先插入行列再写入数据、先清除再写入、批量合并+调整行高列宽 - `group-dimension` 在 batch 中只适合默认展开分组;需要 `--group-state fold` 时请使用独立 `dws sheet group-dimension` - `table-put` 不支持放进 batch-update;结构化 table 请用独立 `dws sheet table-put` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md index 0b135aa4..05b5f0c0 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md @@ -7,6 +7,7 @@ 用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉": - 设置下拉列表 → `set-dropdown` - 设置多选下拉 → `set-dropdown --multi-select` +- 引用单元格区域作为候选项 → `set-dropdown --source-sheet-id ... --source-range ...` 用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项": - 获取下拉列表配置 → `get-dropdown` @@ -29,18 +30,25 @@ Example: dws sheet set-dropdown --node --sheet-id --range "B2:B50" \ --options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \ --multi-select + + # 引用同一工作簿内另一工作表的区域作为候选项来源 + dws sheet set-dropdown --node --sheet-id --range "C2:C100" \ + --source-sheet-id --source-range "T1:T3" Flags: - --node string 表格文档 ID 或 URL (必填) - --sheet-id string 工作表 ID 或名称 (必填) - --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) - --options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]' - --multi-select 是否允许多选(默认单选) + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 目标单元格范围,A1 表示法,如 A2:A100 (必填) + --options string Inline 下拉选项 JSON 数组,与 --source-range 二选一 + --source-sheet-id string SourceRange 来源工作表 ID,与 --source-range 同时指定 + --source-range string SourceRange 来源区域,与 --options 二选一;不带工作表前缀 + --multi-select 是否允许多选(默认单选) ``` -在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。 -- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。 +在指定单元格范围内设置下拉列表。Inline 模式直接存储选项;SourceRange 模式引用同一工作簿内的来源区域,可跨工作表,并支持普通区域、整行和整列。 +- **用途**:为单元格配置静态选项或区域来源下拉,两种模式都支持多选;颜色仅 Inline 支持。 - **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。 -- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。 +- **注意**:`--options` 与 `--source-range` 必须且只能指定一个。`--source-range` 只写 `T1:T3`、`T:T`、`1:3` 这类 A1 区域,来源工作表通过 `--source-sheet-id` 单独指定;不接受工作表前缀、公式或多区域。SourceRange 颜色写入暂不支持。 +- **结构操作行为**:已验证的工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 `valid`;已验证的 `move-dimension` 场景会使其变为 `invalid`。列删除、删除整个来源区域或来源工作表等场景未覆盖,不能预设结果;结构操作后先回读 `sourceRangeStatus`,仅在 `invalid` 时重新选择来源并写入。 ### 获取下拉列表配置 ``` @@ -55,10 +63,10 @@ Flags: --range string 查询范围,A1 表示法,如 A1:A100 (必填) ``` -查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。 +查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -81,14 +89,15 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | -| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | +| `set-dropdown` | `range` 实际设置范围、`enableMultiSelect` 是否多选;仅 Inline 模式返回 `optionCount` | 确认下拉列表设置成功 | +| `get-dropdown` | `hasDropdown`、`dataValidations`;按 `sourceType` 区分 Inline 与 SourceRange | 查看已有下拉配置 | | `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 - ★ **`--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 +- `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 +- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 +- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md index 0e2ae054..f8008d0e 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md @@ -13,7 +13,7 @@ | 读取目的 | 推荐命令 | 说明 | |---------|---------|------| -| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 | +| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 30,000 单元格和 maxChars 防爆 | | 按 table/dataframe 协议读取 | `table-get` | 返回 `columns` / `data` / `dtypes` / `formats`;默认首行为表头 | | 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation | | 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) | @@ -52,7 +52,10 @@ Flags: - `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算 - `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号 - `rowIndices` — 行号映射数组(如 `[1,2,3]`) -- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取 +- `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 +- `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -63,10 +66,15 @@ Flags: | `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 | | `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 | -**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批: -- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 -- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` …… -- 单次建议 ≤5000 单元格 +**大表分批读取**:`csv-get` 不会自动发起后续请求。当 `hasMore=true` 时: +- 显式传了 `--range` 时,该范围是完整目标;未传时,以 `resolvedRange` 为完整目标 +- 根据 `returnedRange` 的结束行,从下一行开始显式构造新的 `--range`;例如目标是 `A1:A30001`、本次返回 `A1:A30000`,则继续读 `A30001:A30001` +- `max_cells` 不能通过增大 `--max-chars` 解决;`max_chars` 且没有 `returnedRange` 时,增大 `--max-chars` 或缩小列范围 +- 上述完成度协议覆盖未传 `--range` 和显式有限矩形范围;`A:A`、`1:1` 等非有限范围可能不返回完成度字段,建议先用 `sheet info` 的 `nonEmptyRange` 换算成有限范围再读 +- 即使未命中硬上限,单次仍建议控制在 5000 单元格以内,减少超时和 token 消耗 + +**工作簿过大失败与部分成功不同**:收到顶层 +`errorCode=forbidden.document.sizeOverLimit` 和对应 `errorMessage` 时,表示工作簿整体无法装载。应停止读取并提示创建更小副本或拆分工作簿;缩小 `--range` 不能解决这类错误。不要把它当成 `hasMore=true` 的可续读结果。 ### 以 table/dataframe 协议读取结构化数据 ``` @@ -130,6 +138,8 @@ Flags: ``` **返回字段说明**: +- `rowIndices` / `colIndices` — 本次返回的真实行号与列字母映射 +- `hasMore` / `truncationReasons` / `resolvedRange` / `returnedRange` — 语义与 `csv-get` 相同;`range read` 的 `truncationReasons` 主要为 `max_cells` - `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下: | 字段 | 类型 | 是否必有 | 说明 | @@ -146,10 +156,16 @@ Flags: | type | 字段 | 说明 | |------|------|------| -| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 | -| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` Inline | `sourceType: "inline"` | 静态选项模式 | +| `dropdown` Inline | `options: [{value: string, color?: string}]` | 下拉选项列表 | +| `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | +| `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | +| `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | +SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 + **hyperlink 结构**: | type | 字段 | 说明 | @@ -216,7 +232,7 @@ Flags: **公式校验建议**:写公式后不要只看写入返回结果。先用 `formula` 模式确认公式文本已落表,再运行 `formula-verify` 聚合扫描错误;关键业务数值继续用 `raw_value` 抽样对账。详见 [sheet-formula](./sheet-formula.md)。 -**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略: +**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分批读取策略,每次都必须检查 `hasMore`: - 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界 - 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` …… - 避免不传 `--range` 直接读取整个大工作表 @@ -232,7 +248,7 @@ dws sheet list --node --format json # 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等) dws sheet info --node --sheet-id --format json -# 3. 读取全部数据 +# 3. 读取数据(只有 hasMore=false 才代表目标范围已完整返回) dws sheet range read --node --sheet-id --format json # 4. 读取指定区域 @@ -251,6 +267,8 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败 - `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据 - `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`) +- ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 +- ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md b/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md index eaa5e695..9050fb43 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-write-data.md @@ -386,9 +386,14 @@ Flags: **dropdown(下拉列表)**: ```json { "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } } +{ "dataValidation": { "type": "dropdown", "sourceRange": {"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"}, "enableMultiSelect": false } } ``` -- `options`:必填,`[{value, color?}]` 数组 +- `options` 与 `sourceRange` 必须且只能提供一个 +- Inline:`options` 为非空 `[{value, color?}]` 数组 +- SourceRange:`sourceRange` 为 `{sheetId,a1Notation}`;可引用同一工作簿内其他工作表,支持普通区域、整行和整列。`a1Notation` 不带工作表前缀,不接受公式或多区域;颜色写入暂不支持 - `enableMultiSelect`:可选,是否多选,默认 false +- SourceRange 在已验证的工作表重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读 `sourceRangeStatus`,仅 `invalid` 时重新选源写入 +- 复合 cell 写入中,如果值或样式已成功落盘但 SourceRange 校验失败,服务端可返回 `success:true` 并通过 `message` 说明下拉未创建;必须检查 `message` 并按需回读 **checkbox(复选框)**: ```json diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 849d9191..5d8ef72b 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -1157,7 +1157,7 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { `test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`, `test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`, `echo "TEST_HEAD_REF=$(git rev-parse HEAD)"`, - `list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"`, + `list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"`, "needs.lint.outputs.full_suite != 'true'", `name: "Test (race: ${{ matrix.shard }})"`, "name: Test (workflow and release contracts)", @@ -1219,16 +1219,20 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { } // The focused path fans the impacted set across the same shards as test-race // and runs each shard the way test-race runs it, so no single job carries - // internal/app together with its reverse dependencies. internal/app keeps its - // package-level headroom through the process-isolating helper instead of one - // long -timeout, which is strictly stronger: every process releases the - // framework registries it populated. release-scripts is asserted because its - // dedicated job only runs at full-suite or release-sensitive scope, so losing - // it here would silently stop testing test/scripts changes. + // internal/app together with its reverse dependencies. internal/app is split + // further into one shard per bounded partition, which keeps its package-level + // headroom through the process-isolating helper instead of one long -timeout + // and is strictly stronger than a single app job: every partition process + // releases the framework registries it populated, and the partitions run + // concurrently rather than end to end. Each partition shard still selects the + // same single internal/app package, so the impacted-package query maps the + // shard name back to app. release-scripts is asserted because its dedicated + // job only runs at full-suite or release-sensitive scope, so losing it here + // would silently stop testing test/scripts changes. for _, want := range []string{ - `if [ "$TEST_SHARD" = "app" ]; then`, + `app-*) package_shard=app ;;`, `test "${#packages[@]}" -eq 1`, - `./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`, + `./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"`, `if [ "$TEST_SHARD" = "release-scripts" ]; then`, `go test -v -count=1 -timeout=10m "${packages[@]}"`, "timeout_budget=12m", @@ -1249,14 +1253,16 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) { t.Fatal("Code Admission workflow missing race test job boundaries") } raceJob := admission[raceStart:raceEnd] - // The app shard uses independently bounded test processes so process-global - // command registries are released before the Schema assembly peak. Other full - // race shards retain the dynamic package timeout: default/floor 12m, with - // cli/smoke raised to 15m on slower hosted runners. + // internal/app is carried by one shard per bounded partition, so process-global + // command registries are released with each partition process and the Schema + // assembly peak no longer sits in front of the other partitions. Each + // partition shard resolves back to the same single internal/app package. + // Other full race shards retain the dynamic package timeout: default/floor + // 12m, with cli/smoke raised to 15m on slower hosted runners. for _, want := range []string{ - `if [ "$TEST_SHARD" = "app" ]; then`, + `app-*) package_shard=app ;;`, `test "${#packages[@]}" -eq 1`, - `./scripts/ci/run-app-race-tests.sh run "${packages[0]}"`, + `./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"`, "timeout_budget=12m", `if [ "$TEST_SHARD" = "cli" ] ||`, `[ "$TEST_SHARD" = "smoke" ]; then`, diff --git a/test/scripts/ci_test_package_plan_test.go b/test/scripts/ci_test_package_plan_test.go index 0d7944f2..b8250c19 100644 --- a/test/scripts/ci_test_package_plan_test.go +++ b/test/scripts/ci_test_package_plan_test.go @@ -99,6 +99,74 @@ func TestCIAppRacePartitionsCoverTopLevelTestsExactlyOnce(t *testing.T) { } } +// TestCIAppRacePartitionMatrixMatchesHelper pins the workflow's app partition +// shards to the partition set the helper actually runs. The partitions are +// separate CI jobs now, so the helper's own "covered exactly once" check can no +// longer prove the whole package ran: a partition the helper knows about but no +// matrix shard dispatches would silently stop running while every job stays +// green. Both directions are asserted so a stale matrix shard fails too. +func TestCIAppRacePartitionMatrixMatchesHelper(t *testing.T) { + root := testPackagePlanRoot(t) + script := filepath.Join(root, "scripts", "ci", "run-app-race-tests.sh") + cmd := exec.Command("sh", script, "list-partitions") + cmd.Dir = root + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s list-partitions failed: %v\n%s", script, err, output) + } + partitions := strings.Fields(string(output)) + if len(partitions) == 0 { + t.Fatalf("list-partitions returned no partitions: %q", output) + } + + workflow, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("read ci.yml: %v", err) + } + admission := string(workflow) + + for _, job := range []struct { + name string + startMark string + endMark string + }{ + {"test-focused", "\n test-focused:\n", "\n test-race:\n"}, + {"test-race", "\n test-race:\n", "\n test-release-scripts:\n"}, + } { + start := strings.Index(admission, job.startMark) + end := strings.Index(admission, job.endMark) + if start < 0 || end <= start { + t.Fatalf("ci.yml is missing %s job boundaries", job.name) + } + body := admission[start:end] + + for _, partition := range partitions { + want := "- app-" + partition + if !strings.Contains(body, want) { + t.Errorf("%s matrix is missing shard %q for a partition the helper runs", job.name, want) + } + } + + for _, line := range strings.Split(body, "\n") { + shard := strings.TrimSpace(line) + if !strings.HasPrefix(shard, "- app-") { + continue + } + name := strings.TrimPrefix(shard, "- app-") + matched := false + for _, partition := range partitions { + if partition == name { + matched = true + break + } + } + if !matched { + t.Errorf("%s matrix shard %q has no matching helper partition", job.name, shard) + } + } + } +} + func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) { root := testPackagePlanRoot(t) fakeBin := t.TempDir()