feat: migrate doc and todo primary parameters to #85349380

This commit is contained in:
wxianfeng committed 2026-08-18 19:25:17 +08:00
1 parent 7fbe7593c8
commit 290f39ecb8
4 files changed
+249 -33

No files matched your search

+23 -15
View File
@@ -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 <DOC_ID> --text \"这是一段文字\" --format json",
"dws doc block insert --node <DOC_ID> --content \"这是一段文字\" --format json",
"dws doc block insert --node <DOC_ID> --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 <nodeId>
可用 --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 <nodeId>
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 <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\" --format json"},
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <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{
+20 -10
View File
@@ -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 <taskId> --file-path <filePath>
Example: ` dws todo task add-attachment --task-id <taskId> --file <filePath>
# 查询 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 <taskId> --file-path /path/to/file.pdf",
"dws todo task add-attachment --task-id <taskId> --file-path /path/to/file.pdf --format json",
"dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf",
"dws todo task add-attachment --task-id <taskId> --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(必填)")
+9 -8
View File
@@ -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/<DOC_UUID>\" --text \"追加一行备注\"",
"dws doc +doc-append --doc DOC_ID --content \"补充说明:本方案已评审通过。\"",
"dws doc +doc-append --doc \"https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>\" --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/<DOC_UUID>" --text "追加一行备注"`,
`dws doc +doc-append --doc DOC_ID --content "补充说明:本方案已评审通过。"`,
`dws doc +doc-append --doc "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --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
@@ -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{}
}