586 lines
22 KiB
Go
586 lines
22 KiB
Go
package helpers
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
// ── batch-update: CLI 命令名 → MCP toolName 翻译层 ──────────────────────────────
|
||
//
|
||
// 设计参考 lark/cli 的 batch_op_dispatch.go:
|
||
// 每个原子命令提供 BuildXxxArgs 函数(CLI flag → MCP param),
|
||
// translateBatchOp 通过 dispatch 表引用这些函数,不重复维护映射关系。
|
||
|
||
// batchOpMapping maps a CLI command name to its MCP tool name and builder function.
|
||
type batchOpMapping struct {
|
||
mcpTool string
|
||
build func(input map[string]any) map[string]any
|
||
}
|
||
|
||
// batchOpDispatch is the dispatch table for batch-update sub-operations.
|
||
// Each entry references the BuildXxxArgs function from the command's own file.
|
||
var batchOpDispatch = map[string]batchOpMapping{
|
||
"range clear": {"clear_range", BuildClearRangeArgs},
|
||
"range update": {"set_cell_range", BuildSetCellRangeArgs},
|
||
"merge-cells": {"merge_range", BuildMergeCellsArgs},
|
||
"unmerge-cells": {"unmerge_range", BuildUnmergeCellsArgs},
|
||
"range fill": {"fill_range", BuildFillRangeArgs},
|
||
"range copy-to": {"copy_range", BuildCopyRangeArgs},
|
||
"add-dimension": {"add_dimension", BuildAddDimensionArgs},
|
||
"delete-dimension": {"delete_dimension", BuildDeleteDimensionArgs},
|
||
"move-dimension": {"move_dimension", BuildMoveDimensionArgs},
|
||
"set-dropdown": {"insert_dropdown_lists", BuildSetDropdownArgs},
|
||
"delete-dropdown": {"delete_dropdown_lists", BuildDeleteDropdownArgs},
|
||
"csv-put": {"set_range_from_csv", BuildCsvPutArgs},
|
||
"delete-float-image": {"delete_float_image", BuildDeleteFloatImageArgs},
|
||
"update-dimension": {"update_dimension", BuildUpdateDimensionArgs},
|
||
"group-dimension": {"group_dimension", BuildGroupDimensionArgs},
|
||
"ungroup-dimension": {"ungroup_dimension", BuildUngroupDimensionArgs},
|
||
}
|
||
|
||
// translateBatchOp translates a batch operation from CLI format to MCP format.
|
||
// toolName must be a CLI command name (e.g. "range clear", "range update").
|
||
// input keys must be CLI flag names without -- prefix (e.g. "sheet-id", "range").
|
||
// Returns error if toolName is not a recognized CLI command name.
|
||
func translateBatchOp(op map[string]any) (map[string]any, error) {
|
||
toolName, _ := op["toolName"].(string)
|
||
input, _ := op["input"].(map[string]any)
|
||
if input == nil {
|
||
input = map[string]any{}
|
||
}
|
||
|
||
// Look up CLI command name in dispatch table
|
||
mapping, ok := batchOpDispatch[toolName]
|
||
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,
|
||
"input": mapping.build(input),
|
||
}, 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。
|
||
// 目前集中放在此文件;后续拆分命令文件时可移到各命令所在文件。
|
||
|
||
// batchStr extracts a string from input map, checking multiple key aliases.
|
||
func batchStr(input map[string]any, keys ...string) string {
|
||
for _, k := range keys {
|
||
if v, ok := input[k]; ok {
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func batchStrOr(input map[string]any, key, defaultVal string) string {
|
||
if v, ok := input[key]; ok && v != nil {
|
||
s := fmt.Sprintf("%v", v)
|
||
if s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return defaultVal
|
||
}
|
||
|
||
func batchInt(input map[string]any, keys ...string) int {
|
||
for _, k := range keys {
|
||
if v, ok := input[k]; ok {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return int(n)
|
||
case int:
|
||
return n
|
||
default:
|
||
var i int
|
||
fmt.Sscanf(fmt.Sprintf("%v", v), "%d", &i)
|
||
return i
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// BuildClearRangeArgs converts CLI flags to MCP params for clear_range.
|
||
func BuildClearRangeArgs(input map[string]any) map[string]any {
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
}
|
||
t := batchStrOr(input, "type", "content")
|
||
args["type"] = t
|
||
return args
|
||
}
|
||
|
||
// BuildSetCellRangeArgs converts CLI flags to MCP params for set_cell_range.
|
||
func BuildSetCellRangeArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"rangeAddress": batchStr(input, "range"),
|
||
"cells": input["values"],
|
||
}
|
||
}
|
||
|
||
// BuildMergeCellsArgs converts CLI flags to MCP params for merge_range.
|
||
func BuildMergeCellsArgs(input map[string]any) map[string]any {
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
}
|
||
if mt := batchStr(input, "merge-type"); mt != "" {
|
||
args["mergeType"] = mt
|
||
} else {
|
||
args["mergeType"] = "mergeAll"
|
||
}
|
||
return args
|
||
}
|
||
|
||
// BuildUnmergeCellsArgs converts CLI flags to MCP params for unmerge_range.
|
||
func BuildUnmergeCellsArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
}
|
||
}
|
||
|
||
// BuildFillRangeArgs converts CLI flags to MCP params for fill_range.
|
||
func BuildFillRangeArgs(input map[string]any) map[string]any {
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"sourceRange": batchStr(input, "source-range"),
|
||
"destinationRange": batchStr(input, "target-range"),
|
||
}
|
||
if ft := batchStr(input, "fill-type"); ft != "" {
|
||
args["fillType"] = ft
|
||
}
|
||
return args
|
||
}
|
||
|
||
// BuildCopyRangeArgs converts CLI flags to MCP params for copy_range.
|
||
func BuildCopyRangeArgs(input map[string]any) map[string]any {
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"sourceRange": batchStr(input, "source-range"),
|
||
"destinationRange": batchStr(input, "target-range"),
|
||
}
|
||
if v := batchStr(input, "target-sheet-id"); v != "" {
|
||
args["targetSheetId"] = v
|
||
}
|
||
if v := batchStr(input, "paste-type"); v != "" {
|
||
args["pasteType"] = v
|
||
}
|
||
return args
|
||
}
|
||
|
||
// BuildAddDimensionArgs converts CLI flags to MCP params for add_dimension.
|
||
func BuildAddDimensionArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"dimension": batchStr(input, "dimension"),
|
||
"length": batchInt(input, "length"),
|
||
}
|
||
}
|
||
|
||
// BuildDeleteDimensionArgs converts CLI flags to MCP params for delete_dimension.
|
||
func BuildDeleteDimensionArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"dimension": batchStr(input, "dimension"),
|
||
"startIndex": batchInt(input, "position", "startIndex"),
|
||
"count": batchInt(input, "length", "count"),
|
||
}
|
||
}
|
||
|
||
// BuildMoveDimensionArgs converts CLI flags to MCP params for move_dimension.
|
||
func BuildMoveDimensionArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"dimension": batchStr(input, "dimension"),
|
||
"startIndex": batchInt(input, "start-index", "startIndex"),
|
||
"endIndex": batchInt(input, "end-index", "endIndex"),
|
||
"destinationIndex": batchInt(input, "destination-index", "destinationIndex"),
|
||
}
|
||
}
|
||
|
||
// BuildSetDropdownArgs converts CLI flags to MCP params for insert_dropdown_lists.
|
||
func BuildSetDropdownArgs(input map[string]any) map[string]any {
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
}
|
||
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
|
||
}
|
||
return args
|
||
}
|
||
|
||
// BuildDeleteDropdownArgs converts CLI flags to MCP params for delete_dropdown_lists.
|
||
func BuildDeleteDropdownArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
}
|
||
}
|
||
|
||
// BuildCsvPutArgs converts CLI flags to MCP params for set_range_from_csv.
|
||
// Resolves @filepath and - stdin to CSV text.
|
||
func BuildCsvPutArgs(input map[string]any) map[string]any {
|
||
csvVal := batchStr(input, "csv")
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"csv": resolveCsvContent(csvVal),
|
||
"startCell": batchStr(input, "start-cell"),
|
||
}
|
||
if v, ok := input["allow-overwrite"]; ok {
|
||
args["allowOverwrite"] = v
|
||
}
|
||
return args
|
||
}
|
||
|
||
// BuildDeleteFloatImageArgs converts CLI flags to MCP params for delete_float_image.
|
||
func BuildDeleteFloatImageArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"floatImageId": batchStr(input, "float-image-id"),
|
||
}
|
||
}
|
||
|
||
// BuildUpdateDimensionArgs converts CLI flags to MCP params for update_dimension.
|
||
// startIndex 传递 A1 表示法字符串(与独立工具一致),由服务端转换为 0-based 整数。
|
||
func BuildUpdateDimensionArgs(input map[string]any) map[string]any {
|
||
args := map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"dimension": strings.ToUpper(batchStr(input, "dimension")),
|
||
"startIndex": batchStr(input, "start-index"),
|
||
"length": batchInt(input, "length"),
|
||
}
|
||
if v := batchInt(input, "pixel-size", "pixelSize"); v != 0 {
|
||
args["pixelSize"] = v
|
||
}
|
||
if v, ok := input["hidden"]; ok {
|
||
args["hidden"] = v
|
||
}
|
||
return args
|
||
}
|
||
|
||
// BuildGroupDimensionArgs converts CLI flags to MCP params for group_dimension.
|
||
func BuildGroupDimensionArgs(input map[string]any) map[string]any {
|
||
groupState := batchStr(input, "group-state", "groupState")
|
||
if groupState == "" {
|
||
groupState = "expand"
|
||
}
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
"groupState": groupState,
|
||
}
|
||
}
|
||
|
||
// BuildUngroupDimensionArgs converts CLI flags to MCP params for ungroup_dimension.
|
||
func BuildUngroupDimensionArgs(input map[string]any) map[string]any {
|
||
return map[string]any{
|
||
"sheetId": batchStr(input, "sheet-id"),
|
||
"range": batchStr(input, "range"),
|
||
}
|
||
}
|
||
|
||
// resolveCsvContent resolves @filepath and - stdin to CSV text, matching standalone csv-put behavior.
|
||
func resolveCsvContent(csvVal string) string {
|
||
switch {
|
||
case csvVal == "-":
|
||
data, err := io.ReadAll(os.Stdin)
|
||
if err != nil {
|
||
return csvVal
|
||
}
|
||
csvVal = string(data)
|
||
case strings.HasPrefix(csvVal, "@"):
|
||
data, err := os.ReadFile(strings.TrimPrefix(csvVal, "@"))
|
||
if err != nil {
|
||
return csvVal
|
||
}
|
||
csvVal = string(data)
|
||
}
|
||
csvVal = strings.ReplaceAll(csvVal, "\r", "")
|
||
csvVal = strings.TrimPrefix(csvVal, "\xef\xbb\xbf")
|
||
return csvVal
|
||
}
|
||
|
||
// ── batch-update / batch-clear 命令定义 ──────────────────────────────────────────
|
||
|
||
func requireSheetMutationConfirmation(cmd *cobra.Command, operation, targetHint string) error {
|
||
// Let dry-run reach callMCPTool so it emits the exact translated preview.
|
||
// The ToolCaller is the authoritative execution boundary and mirrors the
|
||
// root --dry-run flag; do not bypass confirmation on a flag-only mismatch.
|
||
if deps != nil && deps.Caller != nil && deps.Caller.DryRun() {
|
||
return nil
|
||
}
|
||
if commandBoolFlag(cmd, "yes") {
|
||
return nil
|
||
}
|
||
return apperrors.NewValidation(
|
||
fmt.Sprintf("%s可能删除工作表内容或结构;获得用户确认后加 --yes 执行", operation),
|
||
apperrors.WithReason("confirmation_required"),
|
||
apperrors.WithHint(fmt.Sprintf("先确认%s;用户明确同意后以相同参数追加 --yes", targetHint)),
|
||
apperrors.WithActions(fmt.Sprintf("确认%s", targetHint), "获得用户确认后使用 --yes 执行"),
|
||
)
|
||
}
|
||
|
||
const sheetMutationConfirmationGuardAnnotation = "dws.sheet.confirmation-guard"
|
||
|
||
// protectSheetMutationCommand marks a Sheet leaf as covered by the Schema→runtime
|
||
// confirmation gate and installs the Sheet --yes-only runtime guard.
|
||
//
|
||
// Sheet destructive commands intentionally do NOT honor interactive or piped
|
||
// stdin answers (unlike corecmd.ConfirmSafety). Agent/CI must pass --yes.
|
||
// When DeclareLeafMetadata already wrapped ConfirmSafety, this outer guard
|
||
// still wraps outside that pipeline. Order: ContractValidate (if any) →
|
||
// requireSheetMutationTargets (--node) → requireSheetMutationConfirmation →
|
||
// inner contract wrap. Missing --node fails before the Sheet --yes-only
|
||
// prompt. With --yes both confirmation layers bypass.
|
||
//
|
||
// Transitional dual gate: two runtime confirmation sources (outer Sheet
|
||
// --yes-only + inner ConfirmSafety). Do not remove the outer guard without
|
||
// proving ConfirmSafety alone keeps the --yes-only Sheet policy
|
||
// (see TestSheetMutationGuardRejectsPipedYesEvenWithContractConfirmSafety and
|
||
// the declare_leaf+sheet_marker assertion in
|
||
// TestUserRequiredSafetyHomologyWithRuntimeGate).
|
||
func protectSheetMutationCommand(cmd *cobra.Command, operation, targetHint string) {
|
||
if cmd == nil {
|
||
panic("protect sheet mutation command: nil command")
|
||
}
|
||
if cmd.Annotations != nil && cmd.Annotations[sheetMutationConfirmationGuardAnnotation] == "true" {
|
||
panic(fmt.Sprintf("protect sheet mutation command: duplicate guard on %q", cmd.CommandPath()))
|
||
}
|
||
if cmd.Annotations == nil {
|
||
cmd.Annotations = make(map[string]string)
|
||
}
|
||
cmd.Annotations[sheetMutationConfirmationGuardAnnotation] = "true"
|
||
originalRunE := cmd.RunE
|
||
if originalRunE == nil {
|
||
panic(fmt.Sprintf("protect sheet mutation command: %q has no RunE", cmd.CommandPath()))
|
||
}
|
||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||
if validate := ContractValidate(cmd); validate != nil {
|
||
if err := validate(cmd, args); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Local target checks before the Sheet --yes-only prompt (RFC §5.1).
|
||
// Most destructive Sheet leaves require --node (with URL/id aliases).
|
||
if err := requireSheetMutationTargets(cmd); err != nil {
|
||
return err
|
||
}
|
||
if err := requireSheetMutationConfirmation(cmd, operation, targetHint); err != nil {
|
||
return err
|
||
}
|
||
return originalRunE(cmd, args)
|
||
}
|
||
}
|
||
|
||
// requireSheetMutationTargets fails closed on missing document identity before
|
||
// any confirmation prompt. Commands without a --node flag are skipped.
|
||
func requireSheetMutationTargets(cmd *cobra.Command) error {
|
||
if cmd == nil || cmd.Flags().Lookup("node") == nil {
|
||
return nil
|
||
}
|
||
_, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
return err
|
||
}
|
||
|
||
// HasSheetMutationConfirmationGuard reports whether a Sheet command is
|
||
// protected by protectSheetMutationCommand. It is exported for the app-level
|
||
// final Schema-to-runtime delivery gate; callers must not set the annotation
|
||
// directly.
|
||
func HasSheetMutationConfirmationGuard(cmd *cobra.Command) bool {
|
||
return cmd != nil && cmd.Annotations != nil && cmd.Annotations[sheetMutationConfirmationGuardAnnotation] == "true"
|
||
}
|
||
|
||
func newBatchUpdateCmd() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "batch-update",
|
||
Short: "批量执行多个写操作(原子事务)",
|
||
Long: `将多个写操作打包为一次原子请求执行。
|
||
|
||
默认严格事务模式:任一子操作失败则整批回滚。
|
||
传 --continue-on-error 切换为宽松模式(遇失败继续执行)。
|
||
由于子操作可包含清除、删除等破坏性操作,真实执行必须获得用户确认后加 --yes。
|
||
使用 --dry-run 可预览翻译后的完整请求,不需要 --yes,也不会调用远程接口。
|
||
|
||
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
|
||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||
group-dimension / ungroup-dimension
|
||
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 命令。
|
||
|
||
--operations 是 JSON 数组,每项包含:
|
||
toolName CLI 命令名(如 "range clear", "range update")
|
||
input 该命令的入参(不含 --node),键用 flag 名去掉 --
|
||
|
||
完整映射表见:
|
||
dingtalk-workspace/references/products/sheet/sheet-batch-operations.md`,
|
||
Example: ` # 批量清除 + 写入 + 合并(获得用户确认后加 --yes)
|
||
dws sheet batch-update --node NODE_ID --operations '[
|
||
{"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":"csv-put","input":{"sheet-id":"Sheet1","start-cell":"C1","csv":"=1+1,\u0027=1+1"}}
|
||
]' --yes
|
||
|
||
# 宽松模式
|
||
dws sheet batch-update --node NODE_ID --continue-on-error --operations '[...]' --yes`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "node", "operations"); err != nil {
|
||
return err
|
||
}
|
||
opsStr := mustGetFlag(cmd, "operations")
|
||
var operations []any
|
||
if err := json.Unmarshal([]byte(opsStr), &operations); err != nil {
|
||
return fmt.Errorf("--operations JSON 解析失败: %w\n hint: --operations 必须是 JSON 数组", err)
|
||
}
|
||
if len(operations) == 0 {
|
||
return fmt.Errorf("--operations 不能为空数组")
|
||
}
|
||
translated := make([]any, 0, len(operations))
|
||
for i, op := range operations {
|
||
opMap, ok := op.(map[string]any)
|
||
if !ok {
|
||
return fmt.Errorf("operations[%d] 不是 object: %v", i, op)
|
||
}
|
||
top, err := translateBatchOp(opMap)
|
||
if err != nil {
|
||
return fmt.Errorf("operations[%d] 翻译失败: %w", i, err)
|
||
}
|
||
translated = append(translated, top)
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": mustGetFlag(cmd, "node"),
|
||
"operations": translated,
|
||
}
|
||
continueOnError, _ := cmd.Flags().GetBool("continue-on-error")
|
||
if continueOnError {
|
||
toolArgs["continueOnError"] = true
|
||
}
|
||
err := callMCPTool("batch_update", toolArgs)
|
||
if err == nil || continueOnError {
|
||
return err
|
||
}
|
||
// 严格事务模式失败:直接透传服务端错误信息
|
||
// flex-table-app 已在错误信息中包含失败操作索引、回滚提示和原因
|
||
return err
|
||
},
|
||
}
|
||
}
|
||
|
||
func newRangeBatchClearCmd() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "batch-clear",
|
||
Short: "批量清除多个区域(原子事务)",
|
||
Long: `批量清除多个区域,一次原子请求。任一区域清除失败则整批回滚。
|
||
|
||
每个 --ranges 项必须包含工作表前缀(格式: "SheetName!A1:B3")。
|
||
不同区域可以属于不同工作表。
|
||
真实执行必须获得用户确认后加 --yes;--dry-run 仅预览且不调用远程接口。`,
|
||
Example: ` dws sheet range batch-clear --node NODE_ID --ranges '["Sheet1!A1:B3","Sheet2!C1:D5"]' --yes
|
||
dws sheet range batch-clear --node NODE_ID --ranges '["Sheet1!A1:Z1000"]' --type all --yes`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "node", "ranges"); err != nil {
|
||
return err
|
||
}
|
||
rangesStr := mustGetFlag(cmd, "ranges")
|
||
var ranges []string
|
||
if err := json.Unmarshal([]byte(rangesStr), &ranges); err != nil {
|
||
return fmt.Errorf("--ranges JSON 解析失败: %w\n hint: --ranges 必须是 JSON 字符串数组,如 '[\"Sheet1!A1:B3\"]'", err)
|
||
}
|
||
if len(ranges) == 0 {
|
||
return fmt.Errorf("--ranges 不能为空数组")
|
||
}
|
||
clearType, _ := cmd.Flags().GetString("type")
|
||
if clearType == "" {
|
||
clearType = "content"
|
||
}
|
||
operations := make([]any, 0, len(ranges))
|
||
for i, rng := range ranges {
|
||
// 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里
|
||
// ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。
|
||
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
operations = append(operations, map[string]any{
|
||
"toolName": "clear_range",
|
||
"input": map[string]any{
|
||
"sheetId": sheetName,
|
||
"range": rangeAddr,
|
||
"type": clearType,
|
||
},
|
||
})
|
||
}
|
||
return callMCPTool("batch_update", map[string]any{
|
||
"nodeId": mustGetFlag(cmd, "node"),
|
||
"operations": operations,
|
||
})
|
||
},
|
||
}
|
||
}
|