From 290f39ecb8fcb07ce8fd77c77ffd9e54330c5efb Mon Sep 17 00:00:00 2001 From: wxianfeng Date: Tue, 11 Aug 2026 23:31:39 +0800 Subject: [PATCH] feat: migrate doc and todo primary parameters to #85349380 --- internal/helpers/doc.go | 38 ++-- internal/helpers/todo.go | 30 ++- internal/shortcut/smart/doc_append.go | 17 +- .../smart/primary_param_migration_test.go | 197 ++++++++++++++++++ 4 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 internal/shortcut/smart/primary_param_migration_test.go diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index c1740468..177f2976 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -15,10 +15,10 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) // ────────────────────────────────────────────────────────── @@ -1018,7 +1018,7 @@ func inferMimeType(fileName string) string { } // buildBlockElement 从 flags 构建块元素 JSON 对象。 -// 优先级: --element (原始 JSON) > --heading > --text +// 优先级: --element (原始 JSON) > --heading > --text (旧名) > --content func buildBlockElement(cmd *cobra.Command) (any, error) { if raw, _ := cmd.Flags().GetString("element"); raw != "" { var obj any @@ -1037,13 +1037,13 @@ func buildBlockElement(cmd *cobra.Command) (any, error) { "heading": map[string]any{"text": h, "level": level}, }, nil } - if t, _ := cmd.Flags().GetString("text"); t != "" { + if t := flagOrFallback(cmd, "text", "content"); t != "" { return map[string]any{ "blockType": "paragraph", "paragraph": map[string]any{"text": t}, }, nil } - return nil, fmt.Errorf("block content required: --text / --heading / --element") + return nil, fmt.Errorf("block content required: --content / --heading / --element") } // buildNodeTransferRunE creates a RunE handler for copy/move commands. @@ -2127,12 +2127,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 Use: "insert", Short: "插入块元素", Long: `向文档插入块元素。通过 --element 传入 JSON 格式的块元素。 -可用 --text 快速插入段落,--heading + --level 快速插入标题。 +可用 --content 快速插入段落,--heading + --level 快速插入标题。 块类型: paragraph, heading, blockquote, callout, columns, orderedList, unorderedList, table, sheet, attachment, slot`, Example: ` # 快捷插入段落 - dws doc block insert --node DOC_ID --text "这是一段文字" + dws doc block insert --node DOC_ID --content "这是一段文字" # 快捷插入标题 dws doc block insert --node DOC_ID --heading "二级标题" --level 2 @@ -2155,7 +2155,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第二项"}]}' # 在指定位置之前插入 - dws doc block insert --node DOC_ID --text "插入内容" --ref-block BLOCK_ID --where before`, + dws doc block insert --node DOC_ID --content "插入内容" --ref-block BLOCK_ID --where before`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { @@ -2241,14 +2241,14 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, Selection: contract.SelectionSpec{ AgentSummary: "向文档插入块元素", - UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --text/--heading,复杂块用 --element JSON"}, + UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --content/--heading,复杂块用 --element JSON"}, AvoidWhen: []string{ "整篇追加 Markdown 优先 doc update --mode append", "插入本地文件附件优先 doc media insert", "删块用 block delete;改已有块用 block update", }, Examples: []string{ - "dws doc block insert --node --text \"这是一段文字\" --format json", + "dws doc block insert --node --content \"这是一段文字\" --format json", "dws doc block insert --node --heading \"二级标题\" --level 2 --format json", }, }, @@ -2265,8 +2265,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 Use: "update", Short: "更新块元素", Long: `更新文档中指定块的内容或样式。需提供 --block-id 和块元素内容。 -可用 --text 快速更新为段落,--heading + --level 快速更新为标题。`, - Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --text "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node +可用 --content 快速更新为段落,--heading + --level 快速更新为标题。`, + Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --content "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node dws doc block update --node DOC_ID --block-id BLOCK_ID --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "block-id"); err != nil { @@ -2338,7 +2338,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 "插入新块用 block insert;删除用 block delete", "改文档显示名用 rename;整篇覆盖用 update overwrite", }, - Examples: []string{"dws doc block update --node --block-id --text \"新内容\" --format json"}, + Examples: []string{"dws doc block update --node --block-id --content \"新内容\" --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "content-format", Property: "format"}, @@ -2748,7 +2748,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 // block insert blockInsertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)") - blockInsertCmd.Flags().String("text", "", "快捷: 段落文本内容") blockInsertCmd.Flags().String("heading", "", "快捷: 标题文本") blockInsertCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)") blockInsertCmd.Flags().String("element", "", "块元素 JSON (高级)") @@ -2762,7 +2761,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 // block update blockUpdateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)") blockUpdateCmd.Flags().String("block-id", "", "目标块 ID (必填)") - blockUpdateCmd.Flags().String("text", "", "快捷: 段落文本内容") blockUpdateCmd.Flags().String("heading", "", "快捷: 标题文本") blockUpdateCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)") blockUpdateCmd.Flags().String("element", "", "块元素 JSON (高级)") @@ -4644,6 +4642,16 @@ CLI 内部自动完成全部流程: RegisterCrossProductAliases(child) } } + // Register the block content Primary after the cross-product pass. Registering + // --content earlier would make the global content/markdown group expand a new + // --markdown alias onto these commands, which is outside this migration. + for _, cmd := range []*cobra.Command{blockInsertCmd, blockUpdateCmd} { + corecmd.RegisterFlags(cmd, []corecmd.FlagSpec{{ + Name: "content", + Usage: "快捷: 段落文本内容", + Aliases: []string{"text"}, + }}) + } // ── deprecated 标记:文件管理命令已迁移到 drive ── deprecatedDocToDrive := map[*cobra.Command]string{ diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 5c62d390..5332513a 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -11,11 +11,11 @@ import ( "strings" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/fatih/color" "github.com/spf13/cobra" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" ) // ────────────────────────────────────────────────────────── @@ -1017,14 +1017,20 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 Short: "上传待办附件", Long: `上传待办附件 ⚠️ 重要:该接口会上传文件到附件,不可用于测试或试探性调用。调用前必须确认待办存在。`, - Example: ` dws todo task add-attachment --task-id --file-path + Example: ` dws todo task add-attachment --task-id --file # 查询 taskId: dws todo task list`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "task-id", "file-path"); err != nil { - return err + filePath := flagOrFallback(cmd, "file-path", "file") + taskID := mustGetFlag(cmd, "task-id") + if taskID == "" && filePath == "" { + return validateRequiredFlags(cmd, "task-id", "file") + } + if taskID == "" { + return validateRequiredFlags(cmd, "task-id") + } + if filePath == "" { + return validateRequiredFlags(cmd, "file") } - - filePath, _ := cmd.Flags().GetString("file-path") meta, err := buildTodoLocalFileMeta(filePath, "", "") if err != nil { return err @@ -1092,8 +1098,8 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 "要添加执行人/参与人/提醒时改用对应 add 命令", }, Examples: []string{ - "dws todo task add-attachment --task-id --file-path /path/to/file.pdf", - "dws todo task add-attachment --task-id --file-path /path/to/file.pdf --format json", + "dws todo task add-attachment --task-id --file /path/to/file.pdf", + "dws todo task add-attachment --task-id --file /path/to/file.pdf --format json", }, }, Parameters: []contract.ParamDecl{ @@ -1247,7 +1253,11 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 todoTaskListSubCmd.Flags().String("task-id", "", "待办任务 ID (必填)") todoTaskListAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)") todoTaskAddAttachment.Flags().String("task-id", "", "待办任务 ID (必填)") - todoTaskAddAttachment.Flags().String("file-path", "", "本地文件路径(必填)") + corecmd.RegisterFlags(todoTaskAddAttachment, []corecmd.FlagSpec{{ + Name: "file", + Usage: "本地文件路径(必填)", + Aliases: []string{"file-path"}, + }}) todoTaskRemoveAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)") todoTaskRemoveAttachmentCmd.Flags().String("attachment-id", "", "待办附件 ID(必填)") diff --git a/internal/shortcut/smart/doc_append.go b/internal/shortcut/smart/doc_append.go index e00f526a..1629e0b6 100644 --- a/internal/shortcut/smart/doc_append.go +++ b/internal/shortcut/smart/doc_append.go @@ -33,7 +33,7 @@ import ( // touching existing content. Params (nodeId/markdown/mode) are copied verbatim // from the update_document append call site in internal/helpers/doc.go. // -// dws doc +doc-append --doc DOC_ID --text "今天的会议结论:下周一上线。" +// dws doc +doc-append --doc DOC_ID --content "今天的会议结论:下周一上线。" var DocAppend = shortcut.Shortcut{ Service: "doc", Command: "+doc-append", @@ -67,27 +67,28 @@ var DocAppend = shortcut.Shortcut{ UseWhen: []string{"当你只想往一篇钉钉文档的最后面补一段文字、又不想动原有内容时使用;内部用文档更新的“追加(append)”模式,把你给的文本安全地拼到文档末尾,不需要你先去查文档块列表、算末尾位置或手工拼块结构。会真实写入文档内容。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{ - "dws doc +doc-append --doc DOC_ID --text \"补充说明:本方案已评审通过。\"", - "dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/\" --text \"追加一行备注\"", + "dws doc +doc-append --doc DOC_ID --content \"补充说明:本方案已评审通过。\"", + "dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/\" --content \"追加一行备注\"", }, }, + Parameters: []contract.ParamDecl{renamedRequiredParam("content", "text")}, }, Flags: []shortcut.Flag{ {Name: "doc", Type: shortcut.FlagString, Desc: "文档 documentId / nodeId(或文档 URL/token)", Required: true}, - {Name: "text", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "要追加到文档末尾的文本", Required: true, Aliases: []string{"text"}}, }, Tips: []string{ - `dws doc +doc-append --doc DOC_ID --text "补充说明:本方案已评审通过。"`, - `dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/" --text "追加一行备注"`, + `dws doc +doc-append --doc DOC_ID --content "补充说明:本方案已评审通过。"`, + `dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/" --content "追加一行备注"`, }, Execute: func(rt *shortcut.RuntimeContext) error { nodeID := strings.TrimSpace(rt.Str("doc")) if nodeID == "" { return apperrors.NewValidation("--doc 不能为空,请提供文档 documentId/nodeId 或文档 URL") } - text := rt.Str("text") + text := rt.StrFirst("text", "content") if strings.TrimSpace(text) == "" { - return apperrors.NewValidation("--text 不能为空,请提供要追加的文本") + return apperrors.NewValidation("--content 不能为空,请提供要追加的文本") } // update_document append: params copied verbatim from the helper's diff --git a/internal/shortcut/smart/primary_param_migration_test.go b/internal/shortcut/smart/primary_param_migration_test.go new file mode 100644 index 00000000..9c6832cf --- /dev/null +++ b/internal/shortcut/smart/primary_param_migration_test.go @@ -0,0 +1,197 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package smart + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestPrimaryParamMigrationSmartPayloadCompatibility(t *testing.T) { + commands := []struct { + name string + baseArgs []string + wantProduct string + wantTool string + payloadKey string + }{ + { + name: "broadcast", + baseArgs: []string{"chat", "+broadcast", "--to", "张三"}, + wantProduct: "chat", + wantTool: "send_personal_message", + payloadKey: "content", + }, + { + name: "dm", + baseArgs: []string{"chat", "+dm", "--to", "张三"}, + wantProduct: "chat", + wantTool: "send_personal_message", + payloadKey: "content", + }, + { + name: "send-to-group", + baseArgs: []string{"chat", "+send-to-group", "--group", "项目冲刺"}, + wantProduct: "chat", + wantTool: "send_personal_message", + payloadKey: "content", + }, + { + name: "doc-append", + baseArgs: []string{"doc", "+doc-append", "--doc", "doc-1"}, + wantProduct: "doc", + wantTool: "update_document", + payloadKey: "markdown", + }, + } + spellings := []struct { + name string + args []string + wantValue string + }{ + {name: "old-only", args: []string{"--text", "legacy-value"}, wantValue: "legacy-value"}, + {name: "new-only", args: []string{"--content", "legacy-value"}, wantValue: "legacy-value"}, + { + name: "both-different-old-wins", + args: []string{"--text", "legacy-value", "--content", "canonical-value"}, + wantValue: "legacy-value", + }, + } + + for _, command := range commands { + payloads := make(map[string]map[string]any, len(spellings)) + for _, spelling := range spellings { + t.Run(command.name+"/"+spelling.name, func(t *testing.T) { + fake := &platformCoverageCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + args := append([]string(nil), command.baseArgs...) + args = append(args, spelling.args...) + args = append(args, "--yes") + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("execute %v: %v", args, err) + } + if len(fake.calls) == 0 { + t.Fatal("shortcut made no MCP call") + } + call := fake.calls[len(fake.calls)-1] + if call.product != command.wantProduct || call.tool != command.wantTool { + t.Fatalf("last call = %s/%s, want %s/%s", call.product, call.tool, command.wantProduct, command.wantTool) + } + if got := smartPrimaryPayloadValue(t, call.args, command.payloadKey); got != spelling.wantValue { + t.Fatalf("payload value = %q, want %q; args=%#v", got, spelling.wantValue, call.args) + } + payloads[spelling.name] = call.args + }) + } + if !reflect.DeepEqual(payloads["old-only"], payloads["new-only"]) { + t.Errorf("%s old/new payloads differ: old=%#v new=%#v", command.name, payloads["old-only"], payloads["new-only"]) + } + if !reflect.DeepEqual(payloads["old-only"], payloads["both-different-old-wins"]) { + t.Errorf("%s old/both payloads differ: old=%#v both=%#v", command.name, payloads["old-only"], payloads["both-different-old-wins"]) + } + } +} + +func smartPrimaryPayloadValue(t *testing.T, args map[string]any, key string) string { + t.Helper() + value, _ := args[key].(string) + if key == "markdown" { + return value + } + var content map[string]string + if err := json.Unmarshal([]byte(value), &content); err != nil { + t.Fatalf("decode markdown content %q: %v", value, err) + } + return content["text"] +} + +func TestPrimaryParamMigrationSmartSurfaceAndContract(t *testing.T) { + tests := []struct { + service string + command string + }{ + {service: "chat", command: "+broadcast"}, + {service: "chat", command: "+dm"}, + {service: "chat", command: "+send-to-group"}, + {service: "doc", command: "+doc-append"}, + } + + root := newPlatformCoverageRoot() + for _, test := range tests { + t.Run(test.service+"/"+test.command, func(t *testing.T) { + cmd, _, err := root.Find([]string{test.service, test.command}) + if err != nil || cmd == nil { + t.Fatalf("find command: %v", err) + } + canonical := cmd.Flags().Lookup("content") + if canonical == nil || canonical.Hidden { + t.Fatalf("canonical --content = %#v, want visible", canonical) + } + legacy := cmd.Flags().Lookup("text") + if legacy == nil || !legacy.Hidden { + t.Fatalf("legacy --text = %#v, want hidden", legacy) + } + if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "content" { + t.Fatalf("--text alias_of = %#v, want content", got) + } + if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 { + t.Fatalf("--text alias origin = %#v", got) + } + + var help bytes.Buffer + cmd.SetOut(&help) + if err := cmd.Help(); err != nil { + t.Fatal(err) + } + if !strings.Contains(help.String(), "--content") || strings.Contains(help.String(), "--text") { + t.Fatalf("help does not expose only the new Primary:\n%s", help.String()) + } + + spec := smartRegisteredShortcut(t, test.service, test.command) + if len(spec.Contract.Parameters) != 1 { + t.Fatalf("ParamDecls = %#v, want one", spec.Contract.Parameters) + } + param := spec.Contract.Parameters[0] + if param.Name != "content" || param.Property != "text" || + param.Required == nil || !*param.Required || param.InterfaceType != "" { + t.Fatalf("content ParamDecl = %#v", param) + } + for _, example := range append(append([]string(nil), spec.Tips...), spec.Contract.Selection.Examples...) { + if strings.Contains(example, "--text") || !strings.Contains(example, "--content") { + t.Fatalf("example does not recommend only --content: %q", example) + } + } + }) + } +} + +func smartRegisteredShortcut(t *testing.T, service, command string) shortcut.Shortcut { + t.Helper() + for _, spec := range shortcut.All() { + if spec.Service == service && spec.Command == command { + return spec + } + } + t.Fatalf("registered shortcut %s %s not found", service, command) + return shortcut.Shortcut{} +}