Files

586 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
})
},
}
}