5160 lines
199 KiB
Go
5160 lines
199 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"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"
|
||
)
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// dws doc — 钉钉文档
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
// httpPutFile uploads file content via HTTP PUT. Package-level for test injection.
|
||
var httpPutFile = defaultHTTPPutFile
|
||
|
||
// SetHTTPPutFile overrides the HTTP PUT function (for testing). Pass nil to restore default.
|
||
func SetHTTPPutFile(fn func(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error) {
|
||
if fn == nil {
|
||
httpPutFile = defaultHTTPPutFile
|
||
return
|
||
}
|
||
httpPutFile = fn
|
||
}
|
||
|
||
func docVersionExists(ctx context.Context, nodeID string, version int) (bool, error) {
|
||
// 注意: 不传 maxResults —— 服务端实际接受的上限小于 schema 声明的 1-50,
|
||
// 传大值会直接报错 (与悟空实现一致: 默认分页大小 + 游标翻页)。
|
||
cursor := ""
|
||
for page := 0; page < 20; page++ {
|
||
toolArgs := map[string]any{"nodeId": nodeID}
|
||
if cursor != "" {
|
||
toolArgs["nextCursor"] = cursor
|
||
}
|
||
text, err := callMCPReadToolReturnTextOnServer(ctx, "doc", "list_doc_versions", toolArgs)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
var payload any
|
||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||
return false, fmt.Errorf("无法解析文档版本列表,已停止回滚以避免假成功: %w", err)
|
||
}
|
||
if docVersionPayloadContains(payload, version) {
|
||
return true, nil
|
||
}
|
||
cursor = docVersionNextCursor(payload)
|
||
if cursor == "" {
|
||
break
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// docVersionNextCursor 从 list_doc_versions 响应中提取分页游标;没有下一页时返回 ""。
|
||
func docVersionNextCursor(v any) string {
|
||
switch val := v.(type) {
|
||
case map[string]any:
|
||
if hasMore, ok := val["hasMore"].(bool); ok && !hasMore {
|
||
return ""
|
||
}
|
||
for _, key := range []string{"nextCursor", "nextToken", "cursor"} {
|
||
if s, ok := val[key].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
for _, key := range []string{"result", "content", "data"} {
|
||
if s := docVersionNextCursor(val[key]); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
for _, item := range val {
|
||
if s := docVersionNextCursor(item); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
case []any:
|
||
for _, item := range val {
|
||
if s := docVersionNextCursor(item); s != "" {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func docVersionPayloadContains(v any, target int) bool {
|
||
switch val := v.(type) {
|
||
case map[string]any:
|
||
for key, item := range val {
|
||
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
|
||
if normalized == "version" || normalized == "versionnumber" || normalized == "versionno" || normalized == "docversion" || normalized == "revision" {
|
||
if docVersionNumberMatches(item, target) {
|
||
return true
|
||
}
|
||
}
|
||
if docVersionPayloadContains(item, target) {
|
||
return true
|
||
}
|
||
}
|
||
case []any:
|
||
for _, item := range val {
|
||
if docVersionPayloadContains(item, target) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func docVersionNumberMatches(v any, target int) bool {
|
||
switch val := v.(type) {
|
||
case float64:
|
||
return int(val) == target && val == float64(target)
|
||
case string:
|
||
n, err := strconv.Atoi(strings.TrimSpace(val))
|
||
return err == nil && n == target
|
||
case json.Number:
|
||
n, err := val.Int64()
|
||
return err == nil && n == int64(target)
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func runDocUpload(cmd *cobra.Command, _ []string) error {
|
||
if workspace := flagOrFallback(cmd, "workspace", "workspace-id"); workspace != "" {
|
||
deps.Out.PrintWarning("⚠️ 'dws doc upload --workspace' is deprecated, use 'dws drive upload --workspace <workspaceId>' instead.")
|
||
}
|
||
|
||
filePath := mustGetFlag(cmd, "file")
|
||
if filePath == "" {
|
||
return fmt.Errorf("flag --file is required")
|
||
}
|
||
|
||
fi, err := os.Stat(filePath)
|
||
if err != nil {
|
||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||
}
|
||
if fi.IsDir() {
|
||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||
}
|
||
|
||
name, _ := cmd.Flags().GetString("name")
|
||
if name == "" {
|
||
name = filepath.Base(filePath)
|
||
} else if filepath.Ext(name) == "" {
|
||
if ext := filepath.Ext(filePath); ext != "" {
|
||
name += ext
|
||
}
|
||
}
|
||
fileSize := fi.Size()
|
||
|
||
folder := docFolderFlag(cmd)
|
||
workspace := flagOrFallback(cmd, "workspace", "workspace-id")
|
||
if err := validateDocFolderID(folder); err != nil {
|
||
return err
|
||
}
|
||
|
||
if deps.Caller.DryRun() {
|
||
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档")
|
||
deps.Out.PrintKeyValue("文件", filePath)
|
||
deps.Out.PrintKeyValue("名称", name)
|
||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||
return nil
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||
defer cancel()
|
||
|
||
// Step 1: get upload credentials
|
||
step1Args := map[string]any{}
|
||
if folder != "" {
|
||
step1Args["folderId"] = folder
|
||
}
|
||
if workspace != "" {
|
||
step1Args["workspaceId"] = workspace
|
||
}
|
||
|
||
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
|
||
return err
|
||
}
|
||
|
||
commitArgs := map[string]any{
|
||
"uploadKey": uploadKey,
|
||
"name": name,
|
||
"fileSize": float64(fileSize),
|
||
}
|
||
if folder != "" {
|
||
commitArgs["folderId"] = folder
|
||
}
|
||
if workspace != "" {
|
||
commitArgs["workspaceId"] = workspace
|
||
}
|
||
if convert, _ := cmd.Flags().GetBool("convert"); convert {
|
||
commitArgs["convertToOnlineDoc"] = true
|
||
}
|
||
|
||
return callMCPTool("commit_uploaded_file", commitArgs)
|
||
}
|
||
|
||
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
|
||
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
|
||
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
|
||
// 兼容告警,调用方负责结果投影。
|
||
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
|
||
step1Args := map[string]any{}
|
||
if folder != "" {
|
||
step1Args["folderId"] = folder
|
||
}
|
||
if workspace != "" {
|
||
step1Args["workspaceId"] = workspace
|
||
}
|
||
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
|
||
return "", err
|
||
}
|
||
commitArgs := map[string]any{
|
||
"uploadKey": uploadKey,
|
||
"name": fileName,
|
||
"fileSize": float64(fileSize),
|
||
}
|
||
if folder != "" {
|
||
commitArgs["folderId"] = folder
|
||
}
|
||
if workspace != "" {
|
||
commitArgs["workspaceId"] = workspace
|
||
}
|
||
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
|
||
}
|
||
|
||
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
|
||
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
|
||
var data map[string]any
|
||
if err = json.Unmarshal([]byte(text), &data); err != nil {
|
||
err = fmt.Errorf("failed to parse upload credentials JSON: %w", err)
|
||
return
|
||
}
|
||
|
||
if result, ok := data["result"].(map[string]any); ok {
|
||
data = result
|
||
}
|
||
|
||
resourceURL, _ = data["resourceUrl"].(string)
|
||
uploadKey, _ = data["uploadKey"].(string)
|
||
|
||
if resourceURL == "" || uploadKey == "" {
|
||
err = fmt.Errorf("incomplete upload credentials: resourceUrl=%q, uploadKey=%q", resourceURL, uploadKey)
|
||
return
|
||
}
|
||
|
||
headers = make(map[string]string)
|
||
if h, ok := data["headers"].(map[string]any); ok {
|
||
for k, v := range h {
|
||
if s, ok := v.(string); ok {
|
||
headers[k] = s
|
||
}
|
||
}
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]string, filePath string, fileSize int64) error {
|
||
file, err := os.Open(filePath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to open file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create upload request: %w", err)
|
||
}
|
||
req.ContentLength = fileSize
|
||
req.Header.Del("Content-Type")
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
client := &http.Client{Timeout: 5 * time.Minute}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("file upload failed: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
// typed httpStatusError 供上层按 401/403 分支重取凭证
|
||
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// httpGetFile downloads file content via HTTP GET. Package-level for test injection.
|
||
var (
|
||
httpGetFile = defaultHTTPGetFile
|
||
docCreateDestination = os.Create
|
||
docCopyContent = io.Copy
|
||
)
|
||
|
||
// SetHTTPGetFile overrides the HTTP GET function (for testing). Pass nil to restore default.
|
||
func SetHTTPGetFile(fn func(ctx context.Context, url string, headers map[string]string, destPath string) error) {
|
||
if fn == nil {
|
||
httpGetFile = defaultHTTPGetFile
|
||
return
|
||
}
|
||
httpGetFile = fn
|
||
}
|
||
|
||
func runDocDownload(cmd *cobra.Command, _ []string) error {
|
||
nodeID := flagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if nodeID == "" {
|
||
return fmt.Errorf("flag --node is required")
|
||
}
|
||
outputPath, _ := cmd.Flags().GetString("output")
|
||
if outputPath == "" {
|
||
return fmt.Errorf("flag --output is required")
|
||
}
|
||
|
||
if deps.Caller.DryRun() {
|
||
deps.Out.PrintKeyValue("操作", "下载钉钉文件")
|
||
deps.Out.PrintKeyValue("节点", nodeID)
|
||
deps.Out.PrintKeyValue("输出", outputPath)
|
||
return nil
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// Step 1: get download URL and signed headers
|
||
deps.Out.PrintInfo("[1/2] 获取下载链接...")
|
||
|
||
text, err := callMCPToolReturnText(ctx, "download_file", map[string]any{
|
||
"nodeId": nodeID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Resolve output path: if it's a directory, append inferred filename
|
||
fi, statErr := os.Stat(outputPath)
|
||
if statErr == nil && fi.IsDir() {
|
||
filename := inferFilename(resourceURL)
|
||
outputPath = filepath.Join(outputPath, filename)
|
||
}
|
||
|
||
// Step 2: HTTP GET to download file
|
||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||
|
||
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
|
||
return err
|
||
}
|
||
|
||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||
return nil
|
||
}
|
||
|
||
// parseDownloadInfo extracts resourceUrl (first URL) and headers from the MCP tool response.
|
||
func parseDownloadInfo(text string) (resourceURL string, headers map[string]string, err error) {
|
||
var data map[string]any
|
||
if err = json.Unmarshal([]byte(text), &data); err != nil {
|
||
err = fmt.Errorf("failed to parse download info JSON: %w", err)
|
||
return
|
||
}
|
||
|
||
if result, ok := data["result"].(map[string]any); ok {
|
||
data = result
|
||
}
|
||
|
||
switch v := data["resourceUrl"].(type) {
|
||
case string:
|
||
resourceURL = v
|
||
case []any:
|
||
if len(v) > 0 {
|
||
resourceURL, _ = v[0].(string)
|
||
}
|
||
}
|
||
|
||
// drive download_file 返回 downloadUrl 而非 resourceUrl
|
||
if resourceURL == "" {
|
||
if v, ok := data["downloadUrl"].(string); ok {
|
||
resourceURL = v
|
||
}
|
||
}
|
||
|
||
if resourceURL == "" {
|
||
err = fmt.Errorf("incomplete download info: resourceUrl is empty")
|
||
return
|
||
}
|
||
|
||
headers = make(map[string]string)
|
||
if h, ok := data["headers"].(map[string]any); ok {
|
||
for k, v := range h {
|
||
if s, ok := v.(string); ok {
|
||
headers[k] = s
|
||
}
|
||
}
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
// inferFilename extracts a filename from a URL, falling back to "download" if unable.
|
||
func inferFilename(rawURL string) string {
|
||
if idx := strings.LastIndex(rawURL, "/"); idx >= 0 && idx < len(rawURL)-1 {
|
||
name := rawURL[idx+1:]
|
||
if qIdx := strings.Index(name, "?"); qIdx >= 0 {
|
||
name = name[:qIdx]
|
||
}
|
||
if decoded, err := url.PathUnescape(name); err == nil {
|
||
name = decoded
|
||
}
|
||
// 解码后可能含 %2F 还原出的路径分隔符(如 "ddmedia/xxx.png"),只取
|
||
// 末段 base 名,避免拼出调用方未创建的子目录导致写文件失败。
|
||
name = strings.ReplaceAll(name, "\\", "/")
|
||
name = filepath.Base(name)
|
||
if name != "" && name != "." && name != "/" {
|
||
return name
|
||
}
|
||
}
|
||
return "download"
|
||
}
|
||
|
||
func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]string, destPath string) error {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
client := &http.Client{Timeout: 10 * time.Minute}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
// typed httpStatusError 供上层按 401/403 分支重取凭证
|
||
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
|
||
}
|
||
|
||
outFile, err := docCreateDestination(destPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer outFile.Close()
|
||
|
||
if _, err := docCopyContent(outFile, resp.Body); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
|
||
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
|
||
// 2. HTTP PUT file content to OSS
|
||
// 3. insert_document_block with attachment element
|
||
// 4. list_document_blocks → prove the uploaded resource is visible in the document
|
||
func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
filePath := mustGetFlag(cmd, "file")
|
||
if filePath == "" {
|
||
return fmt.Errorf("flag --file is required")
|
||
}
|
||
|
||
fileInfo, err := os.Stat(filePath)
|
||
if err != nil {
|
||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||
}
|
||
if fileInfo.IsDir() {
|
||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||
}
|
||
|
||
fileName, _ := cmd.Flags().GetString("name")
|
||
if fileName == "" {
|
||
fileName = filepath.Base(filePath)
|
||
} else if filepath.Ext(fileName) == "" {
|
||
if ext := filepath.Ext(filePath); ext != "" {
|
||
fileName += ext
|
||
}
|
||
}
|
||
|
||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||
if mimeType == "" {
|
||
mimeType = inferMimeType(fileName)
|
||
}
|
||
|
||
fileSize := fileInfo.Size()
|
||
|
||
if deps.Caller.DryRun() {
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"contractVersion": "doc.operation.v1",
|
||
"dry_run": true,
|
||
"preview_kind": "plan",
|
||
"ok": true,
|
||
"status": "success",
|
||
"complete": true,
|
||
"operation": "doc.media_insert",
|
||
"data": map[string]any{
|
||
"executed": false, "nodeId": nodeID, "file": filePath,
|
||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||
},
|
||
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
|
||
})
|
||
}
|
||
|
||
ctx := cmd.Context()
|
||
|
||
// Step 1: get upload credentials (uploadUrl + resourceId)
|
||
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||
|
||
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
|
||
"nodeId": nodeID,
|
||
"fileName": fileName,
|
||
"fileSize": float64(fileSize),
|
||
"mimeType": mimeType,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(credText)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Step 2: HTTP PUT file to OSS
|
||
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
|
||
|
||
ossHeaders := map[string]string{
|
||
"Content-Type": mimeType,
|
||
}
|
||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||
return apperrors.NewAPI(
|
||
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
|
||
apperrors.WithOperation("doc.media_insert"),
|
||
apperrors.WithReason("doc_media_upload_unknown"),
|
||
apperrors.WithFailureStage("upload_oss"),
|
||
apperrors.WithExecutionStarted(true),
|
||
apperrors.WithRetryable(false),
|
||
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
|
||
apperrors.WithDetails(map[string]any{
|
||
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
|
||
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
|
||
}),
|
||
apperrors.WithCause(err),
|
||
)
|
||
}
|
||
|
||
// Step 3: insert block into document
|
||
deps.Out.PrintInfo("[3/4] 插入块到文档...")
|
||
|
||
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
|
||
|
||
var element map[string]any
|
||
if strings.HasPrefix(mimeType, "image/") && resourceURL != "" && fileSize <= maxInlineImageSize {
|
||
// Image files: insert as inline image (paragraph + children image)
|
||
element = map[string]any{
|
||
"blockType": "paragraph",
|
||
"paragraph": map[string]any{
|
||
"text": "",
|
||
},
|
||
"children": []any{
|
||
map[string]any{
|
||
"elementType": "image",
|
||
"properties": map[string]any{
|
||
"src": resourceURL,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
// Non-image files: insert as attachment block
|
||
viewType := "preview"
|
||
if mimeType == "text/markdown" {
|
||
viewType = "summary"
|
||
}
|
||
element = map[string]any{
|
||
"blockType": "attachment",
|
||
"attachment": map[string]any{
|
||
"resourceId": resourceID,
|
||
"type": mimeType,
|
||
"name": fileName,
|
||
"viewType": viewType,
|
||
},
|
||
}
|
||
}
|
||
|
||
insertArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"element": element,
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("index"); cmd.Flags().Changed("index") {
|
||
insertArgs["index"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("where"); v != "" {
|
||
insertArgs["where"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||
insertArgs["referenceBlockId"] = v
|
||
}
|
||
|
||
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
|
||
if err != nil {
|
||
return apperrors.NewAPI(
|
||
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
|
||
apperrors.WithOperation("doc.media_insert"),
|
||
apperrors.WithReason("doc_media_insert_partial"),
|
||
apperrors.WithFailureStage("insert_block"),
|
||
apperrors.WithExecutionStarted(true),
|
||
apperrors.WithRetryable(false),
|
||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
|
||
apperrors.WithDetails(map[string]any{
|
||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
|
||
"steps": []map[string]any{
|
||
{"name": "resolve_upload", "status": "success"},
|
||
{"name": "upload_oss", "status": "success"},
|
||
{"name": "insert_block", "status": "unknown"},
|
||
},
|
||
}),
|
||
apperrors.WithCause(err),
|
||
)
|
||
}
|
||
insertResult := map[string]any{}
|
||
if strings.TrimSpace(insertText) != "" {
|
||
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
|
||
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
|
||
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
|
||
}
|
||
}
|
||
insertedBlockID := insertedDocBlockID(insertResult)
|
||
|
||
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
|
||
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
|
||
if verifyErr != nil {
|
||
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
|
||
}
|
||
if insertedBlockID == "" {
|
||
insertedBlockID = verifiedBlockID
|
||
}
|
||
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"contractVersion": "doc.operation.v1",
|
||
"ok": true,
|
||
"status": "success",
|
||
"complete": true,
|
||
"operation": "doc.media_insert",
|
||
"data": map[string]any{
|
||
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
|
||
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||
"inserted": true, "verified": true,
|
||
},
|
||
"steps": []map[string]any{
|
||
{"name": "resolve_upload", "status": "success"},
|
||
{"name": "upload_oss", "status": "success"},
|
||
{"name": "insert_block", "status": "success"},
|
||
{"name": "verify", "status": "success"},
|
||
},
|
||
})
|
||
}
|
||
|
||
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
|
||
return apperrors.NewAPI(
|
||
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
|
||
apperrors.WithOperation("doc.media_insert"),
|
||
apperrors.WithReason("doc_media_insert_verification_failed"),
|
||
apperrors.WithFailureStage("verify"),
|
||
apperrors.WithExecutionStarted(true),
|
||
apperrors.WithRetryable(false),
|
||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
|
||
apperrors.WithDetails(map[string]any{
|
||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
|
||
"steps": []map[string]any{
|
||
{"name": "resolve_upload", "status": "success"},
|
||
{"name": "upload_oss", "status": "success"},
|
||
{"name": "insert_block", "status": "success"},
|
||
{"name": "verify", "status": "failed"},
|
||
},
|
||
}),
|
||
apperrors.WithCause(cause),
|
||
)
|
||
}
|
||
|
||
var docMediaVerifyWait = waitForDocVerification
|
||
|
||
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
|
||
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
|
||
var lastErr error
|
||
for attempt := 0; attempt <= len(delays); attempt++ {
|
||
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
|
||
if err != nil {
|
||
lastErr = err
|
||
} else {
|
||
lastErr = nil
|
||
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
|
||
return found, nil
|
||
}
|
||
}
|
||
if attempt < len(delays) {
|
||
if err := docMediaVerifyWait(ctx, delays[attempt]); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
}
|
||
if lastErr != nil {
|
||
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
|
||
}
|
||
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
|
||
}
|
||
|
||
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
timer := time.NewTimer(delay)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-timer.C:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
|
||
const pageSize = 50
|
||
const maxItems = 5000
|
||
all := make([]any, 0, pageSize)
|
||
seenPageIdentities := map[string]bool{}
|
||
for start := 0; start < maxItems; start += pageSize {
|
||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var payload map[string]any
|
||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
|
||
}
|
||
payload = nestedDocMap(payload)
|
||
blocks, ok := payload["blocks"].([]any)
|
||
if !ok {
|
||
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
|
||
}
|
||
pageIdentity := docBlockPageIdentity(blocks)
|
||
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
|
||
return nil, fmt.Errorf("list_document_blocks 分页停滞")
|
||
}
|
||
if pageIdentity != "" {
|
||
seenPageIdentities[pageIdentity] = true
|
||
}
|
||
all = append(all, blocks...)
|
||
hasMore, hasMoreKnown := payload["hasMore"].(bool)
|
||
if hasMoreKnown && !hasMore {
|
||
return all, nil
|
||
}
|
||
if !hasMoreKnown {
|
||
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
|
||
return all, nil
|
||
}
|
||
}
|
||
if !hasMoreKnown && len(blocks) < pageSize {
|
||
return all, nil
|
||
}
|
||
if len(blocks) == 0 {
|
||
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("文档块超过安全回读上限")
|
||
}
|
||
|
||
func docBlockPageIdentity(blocks []any) string {
|
||
if len(blocks) == 0 {
|
||
return ""
|
||
}
|
||
ids := make([]string, 0, len(blocks))
|
||
for _, value := range blocks {
|
||
id := ""
|
||
switch block := value.(type) {
|
||
case map[string]any:
|
||
id = directDocBlockIdentity(block)
|
||
if id == "" {
|
||
if element, ok := block["element"].(map[string]any); ok {
|
||
id = directDocBlockIdentity(element)
|
||
}
|
||
}
|
||
case []any:
|
||
if len(block) > 1 {
|
||
if attributes, ok := block[1].(map[string]any); ok {
|
||
id = directDocBlockIdentity(attributes)
|
||
}
|
||
}
|
||
}
|
||
if id == "" {
|
||
return ""
|
||
}
|
||
ids = append(ids, id)
|
||
}
|
||
encoded, _ := json.Marshal(ids)
|
||
return string(encoded)
|
||
}
|
||
|
||
func directDocBlockIdentity(block map[string]any) string {
|
||
for _, key := range []string{"blockId", "id", "uuid", "elementId"} {
|
||
if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func nestedDocMap(data map[string]any) map[string]any {
|
||
for _, key := range []string{"result", "data"} {
|
||
if nested, ok := data[key].(map[string]any); ok {
|
||
return nestedDocMap(nested)
|
||
}
|
||
}
|
||
return data
|
||
}
|
||
|
||
func nestedDocString(value any, keys ...string) string {
|
||
switch typed := value.(type) {
|
||
case map[string]any:
|
||
for _, key := range keys {
|
||
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
}
|
||
orderedKeys := make([]string, 0, len(typed))
|
||
for key := range typed {
|
||
orderedKeys = append(orderedKeys, key)
|
||
}
|
||
sort.Strings(orderedKeys)
|
||
for _, key := range orderedKeys {
|
||
if text := nestedDocString(typed[key], keys...); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
case []any:
|
||
for _, child := range typed {
|
||
if text := nestedDocString(child, keys...); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// insertedDocBlockID only accepts explicit block IDs from the insert result or
|
||
// known response wrappers. Arbitrary IDs may belong to the document, operator,
|
||
// or request and must not become a hard constraint for the media readback.
|
||
func insertedDocBlockID(data map[string]any) string {
|
||
for _, key := range []string{"blockId", "elementId"} {
|
||
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
|
||
return strings.TrimSpace(text)
|
||
}
|
||
}
|
||
for _, wrapper := range []string{"result", "data", "content"} {
|
||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||
if text := insertedDocBlockID(inner); text != "" {
|
||
return text
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
|
||
for _, value := range blocks {
|
||
candidateID := nestedDocString(value, "blockId", "id", "uuid")
|
||
if candidateID == "" || (blockID != "" && candidateID != blockID) {
|
||
continue
|
||
}
|
||
mediaValue := docMediaReadbackValue(value)
|
||
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
|
||
return candidateID
|
||
}
|
||
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
|
||
return candidateID
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func docMediaReadbackValue(value any) any {
|
||
block, ok := value.(map[string]any)
|
||
if !ok {
|
||
return value
|
||
}
|
||
encoded, ok := block["jsonml"].(string)
|
||
if !ok || strings.TrimSpace(encoded) == "" {
|
||
return value
|
||
}
|
||
var decoded any
|
||
if json.Unmarshal([]byte(encoded), &decoded) != nil {
|
||
return value
|
||
}
|
||
return decoded
|
||
}
|
||
|
||
func docNumberAsInt(value any) (int, bool) {
|
||
switch typed := value.(type) {
|
||
case float64:
|
||
if typed >= 0 && typed == float64(int(typed)) {
|
||
return int(typed), true
|
||
}
|
||
case int:
|
||
return typed, typed >= 0
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
|
||
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
|
||
var data map[string]any
|
||
if err = json.Unmarshal([]byte(text), &data); err != nil {
|
||
err = fmt.Errorf("failed to parse attachment upload info JSON: %w", err)
|
||
return
|
||
}
|
||
|
||
if result, ok := data["result"].(map[string]any); ok {
|
||
data = result
|
||
}
|
||
|
||
uploadURL, _ = data["uploadUrl"].(string)
|
||
resourceID, _ = data["resourceId"].(string)
|
||
resourceURL, _ = data["resourceUrl"].(string)
|
||
|
||
if uploadURL == "" || resourceID == "" {
|
||
err = fmt.Errorf("incomplete attachment upload info: uploadUrl=%q, resourceId=%q", uploadURL, resourceID)
|
||
}
|
||
return
|
||
}
|
||
|
||
// inferMimeType guesses a MIME type from the file extension.
|
||
func inferMimeType(fileName string) string {
|
||
ext := strings.ToLower(filepath.Ext(fileName))
|
||
mimeTypes := map[string]string{
|
||
".pdf": "application/pdf",
|
||
".doc": "application/msword",
|
||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
".xls": "application/vnd.ms-excel",
|
||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
".ppt": "application/vnd.ms-powerpoint",
|
||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".svg": "image/svg+xml",
|
||
".webp": "image/webp",
|
||
".mp4": "video/mp4",
|
||
".mp3": "audio/mpeg",
|
||
".wav": "audio/wav",
|
||
".zip": "application/zip",
|
||
".gz": "application/gzip",
|
||
".tar": "application/x-tar",
|
||
".json": "application/json",
|
||
".xml": "application/xml",
|
||
".csv": "text/csv",
|
||
".txt": "text/plain",
|
||
".html": "text/html",
|
||
".md": "text/markdown",
|
||
}
|
||
if mime, ok := mimeTypes[ext]; ok {
|
||
return mime
|
||
}
|
||
return "application/octet-stream"
|
||
}
|
||
|
||
// buildBlockElement 从 flags 构建块元素 JSON 对象。
|
||
// 优先级: --element (原始 JSON) > --heading > --text (旧名) > --content
|
||
func buildBlockElement(cmd *cobra.Command) (any, error) {
|
||
if raw, _ := cmd.Flags().GetString("element"); raw != "" {
|
||
var obj any
|
||
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
|
||
return nil, fmt.Errorf("--element JSON parse failed: %w", err)
|
||
}
|
||
return obj, nil
|
||
}
|
||
if h, _ := cmd.Flags().GetString("heading"); h != "" {
|
||
level, _ := cmd.Flags().GetInt("level")
|
||
if level < 1 || level > 6 {
|
||
level = 1
|
||
}
|
||
return map[string]any{
|
||
"blockType": "heading",
|
||
"heading": map[string]any{"text": h, "level": level},
|
||
}, nil
|
||
}
|
||
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: --content / --heading / --element")
|
||
}
|
||
|
||
// buildNodeTransferRunE creates a RunE handler for copy/move commands.
|
||
// It extracts --node, --folder, --workspace flags and calls the specified MCP tool.
|
||
func buildNodeTransferRunE(mcpToolName string) func(cmd *cobra.Command, args []string) error {
|
||
return func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
}
|
||
if v := docFolderFlag(cmd); v != "" {
|
||
if err := validateDocFolderID(v); err != nil {
|
||
return err
|
||
}
|
||
toolArgs["targetFolderId"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool(mcpToolName, toolArgs)
|
||
}
|
||
}
|
||
|
||
func docFolderFlag(cmd *cobra.Command, extraAliases ...string) string {
|
||
aliases := append([]string{"parent-id", "parent-folder", "parent-node-id", "parent-folder-id"}, extraAliases...)
|
||
return flagOrFallback(cmd, "folder", aliases...)
|
||
}
|
||
|
||
func validateDocFolderID(folderID string) error {
|
||
value := strings.TrimSpace(folderID)
|
||
if value == "" || strings.Contains(value, "alidocs.dingtalk.com") {
|
||
return nil
|
||
}
|
||
|
||
for _, r := range value {
|
||
if r < '0' || r > '9' {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
return fmt.Errorf("invalid doc --folder %q: pure numeric IDs are usually drive dentryId/parent-id values, not DingTalk doc folder nodeId values; use a doc folder nodeId or alidocs folder URL, or omit --folder to use the default doc root", folderID)
|
||
}
|
||
|
||
// previewDocOverwriteDiff reads the current document content and prints a
|
||
// human-readable diff against the incoming markdown without calling the
|
||
// remote update API. Used by `dws doc update --mode overwrite --dry-run`.
|
||
func previewDocOverwriteDiff(ctx context.Context, cmd *cobra.Command, nodeID, newMarkdown string) error {
|
||
text, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{"nodeId": nodeID})
|
||
if err != nil {
|
||
return fmt.Errorf("dry-run read failed: %w", err)
|
||
}
|
||
current := extractMarkdownField(text)
|
||
out := cmd.OutOrStdout()
|
||
fmt.Fprint(out, renderDocOverwriteDiff(nodeID, current, newMarkdown))
|
||
return nil
|
||
}
|
||
|
||
// extractMarkdownField pulls the "markdown" string field out of the JSON
|
||
// returned by get_document_content. Falls back to the raw text when the JSON
|
||
// shape is not recognized.
|
||
func extractMarkdownField(jsonText string) string {
|
||
var body struct {
|
||
Markdown string `json:"markdown"`
|
||
}
|
||
if err := json.Unmarshal([]byte(jsonText), &body); err == nil && body.Markdown != "" {
|
||
return body.Markdown
|
||
}
|
||
return jsonText
|
||
}
|
||
|
||
// renderDocOverwriteDiff returns a unified-diff-style preview comparing the
|
||
// document's current markdown ("before") with the incoming overwrite content
|
||
// ("after"). The format is intentionally simple — sufficient for the agent or
|
||
// user to judge magnitude of the change before passing --yes.
|
||
func renderDocOverwriteDiff(nodeID, before, after string) string {
|
||
beforeLines := strings.Split(before, "\n")
|
||
afterLines := strings.Split(after, "\n")
|
||
var sb strings.Builder
|
||
fmt.Fprintf(&sb, "[dry-run] dws doc update --mode overwrite --node %s\n", nodeID)
|
||
fmt.Fprintf(&sb, "--- current (%d lines, %d bytes)\n", len(beforeLines), len(before))
|
||
fmt.Fprintf(&sb, "+++ incoming (%d lines, %d bytes)\n", len(afterLines), len(after))
|
||
|
||
const headLines = 20
|
||
fmt.Fprintln(&sb, "@@ current (head) @@")
|
||
for i, line := range beforeLines {
|
||
if i >= headLines {
|
||
fmt.Fprintf(&sb, " ... (%d more lines)\n", len(beforeLines)-headLines)
|
||
break
|
||
}
|
||
fmt.Fprintf(&sb, "- %s\n", line)
|
||
}
|
||
fmt.Fprintln(&sb, "@@ incoming (head) @@")
|
||
for i, line := range afterLines {
|
||
if i >= headLines {
|
||
fmt.Fprintf(&sb, " ... (%d more lines)\n", len(afterLines)-headLines)
|
||
break
|
||
}
|
||
fmt.Fprintf(&sb, "+ %s\n", line)
|
||
}
|
||
fmt.Fprint(&sb, "\nNo write performed. Rerun without --dry-run and add --yes to apply.\n")
|
||
return sb.String()
|
||
}
|
||
|
||
func newDocCommand() *cobra.Command {
|
||
// Product-level Agent routing Decl (migrated from selection/doc.json
|
||
// products.doc). Catalog assembly stamps provenance contract_final.
|
||
contract.RegisterProductDecl(contract.ProductDecl{
|
||
ID: "doc",
|
||
Selection: contract.ProductSelectionDecl{
|
||
AgentSummary: "管理钉钉在线文档的正文、块、评论、导入导出、模板与版本",
|
||
UseWhen: []string{
|
||
"创建、读取或编辑在线文档内容,或处理文档块、评论、导入导出、模板和版本时",
|
||
},
|
||
AvoidWhen: []string{
|
||
"文件、目录、上传下载及节点权限已迁移到 drive;知识库空间和成员使用 wiki;不要用于搜索开放平台开发文档",
|
||
},
|
||
},
|
||
})
|
||
root := &cobra.Command{
|
||
Use: "doc",
|
||
Short: "钉钉文档管理",
|
||
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
|
||
|
||
命令结构:
|
||
dws doc info 获取文档元信息
|
||
dws doc read 读取文档内容 (Markdown)
|
||
dws doc create 创建文档
|
||
dws doc update 更新文档内容
|
||
dws doc block [list|insert|update|delete] 块级编辑
|
||
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
|
||
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
|
||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||
dws doc export get 查询导出任务结果 (手动兜底)
|
||
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
|
||
dws doc import get 查询导入任务结果 (手动兜底)
|
||
dws doc template list 获取文档模板列表
|
||
dws doc template search 搜索文档模板
|
||
dws doc template apply 应用文档模板创建新文档
|
||
|
||
文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
searchCmd := &cobra.Command{
|
||
Use: "search",
|
||
Short: "搜索文档",
|
||
Long: `根据关键词搜索当前用户有权限访问的文档列表。不传关键词则返回最近访问的文档。
|
||
|
||
支持按扩展名、创建/访问时间范围、创建者、编辑者、@提及用户、知识库等维度过滤。
|
||
`,
|
||
Example: ` dws doc search --query "会议纪要"
|
||
dws doc search
|
||
dws doc search --extensions pdf,docx
|
||
dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000
|
||
dws doc search --creator-uids uid1,uid2
|
||
dws doc search --workspace-ids wsId1,wsId2`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
toolArgs := map[string]any{}
|
||
if v, _ := cmd.Flags().GetString("query"); v != "" {
|
||
toolArgs["keyword"] = v
|
||
} else if v, _ := cmd.Flags().GetString("keyword"); v != "" {
|
||
toolArgs["keyword"] = v
|
||
}
|
||
if (cmd.Flags().Changed("query") || cmd.Flags().Changed("keyword")) && len(toolArgs) == 0 {
|
||
fmt.Fprintf(os.Stderr, "hint: --query 值为空已忽略,将返回最近访问的文档\n")
|
||
}
|
||
if v, _ := cmd.Flags().GetStringSlice("extensions"); len(v) > 0 {
|
||
toolArgs["extensions"] = v
|
||
}
|
||
if cmd.Flags().Changed("created-from") {
|
||
if v, _ := cmd.Flags().GetInt64("created-from"); v > 0 {
|
||
toolArgs["createdTimeFrom"] = v
|
||
}
|
||
}
|
||
if cmd.Flags().Changed("created-to") {
|
||
if v, _ := cmd.Flags().GetInt64("created-to"); v > 0 {
|
||
toolArgs["createdTimeTo"] = v
|
||
}
|
||
}
|
||
if cmd.Flags().Changed("visited-from") {
|
||
if v, _ := cmd.Flags().GetInt64("visited-from"); v > 0 {
|
||
toolArgs["visitedTimeFrom"] = v
|
||
}
|
||
}
|
||
if cmd.Flags().Changed("visited-to") {
|
||
if v, _ := cmd.Flags().GetInt64("visited-to"); v > 0 {
|
||
toolArgs["visitedTimeTo"] = v
|
||
}
|
||
}
|
||
if v, _ := cmd.Flags().GetStringSlice("creator-uids"); len(v) > 0 {
|
||
toolArgs["creatorUserIds"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetStringSlice("editor-uids"); len(v) > 0 {
|
||
toolArgs["editorUserIds"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetStringSlice("mentioned-uids"); len(v) > 0 {
|
||
toolArgs["mentionedUserIds"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetStringSlice("workspace-ids"); len(v) > 0 {
|
||
toolArgs["workspaceIds"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||
toolArgs["pageSize"] = v
|
||
} else if v, _ := cmd.Flags().GetInt("page-size"); v > 0 {
|
||
toolArgs["pageSize"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "cursor", "page-token"); v != "" {
|
||
toolArgs["pageToken"] = v
|
||
}
|
||
return callMCPTool("search_documents", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(searchCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "search_documents",
|
||
CanonicalPath: "doc.search_documents",
|
||
CLIPath: "doc search",
|
||
PrimaryCLIPath: "doc search",
|
||
},
|
||
Description: "搜索文档(不传关键词返回最近访问)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "search_documents"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "搜索文档(不传关键词返回最近访问)",
|
||
UseWhen: []string{"兼容入口:按关键词搜文档时(已弃用,日常改用 drive search / wiki node search)"},
|
||
AvoidWhen: []string{"全局搜文件用 dws drive search;指定知识库内搜用 dws wiki node search"},
|
||
Examples: []string{"dws doc search --query \"会议纪要\" --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "created-from", Property: "createdTimeFrom"},
|
||
{Name: "created-to", Property: "createdTimeTo"},
|
||
{Name: "creator-uids", Property: "creatorUserIds"},
|
||
{Name: "cursor", Property: "pageToken"},
|
||
{Name: "editor-uids", Property: "editorUserIds"},
|
||
{Name: "limit", Property: "pageSize"},
|
||
{Name: "mentioned-uids", Property: "mentionedUserIds"},
|
||
{Name: "query", Property: "keyword"},
|
||
{Name: "visited-from", Property: "visitedTimeFrom"},
|
||
{Name: "visited-to", Property: "visitedTimeTo"},
|
||
},
|
||
},
|
||
})
|
||
|
||
listCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "遍历文件列表",
|
||
Long: `列出文件夹或知识库下的直接子节点 (文件夹/文档/文件等)。
|
||
定位优先级: --folder > --workspace > 默认 (我的文档根目录)
|
||
|
||
跨语义组别名: --node / --file-id 在 list 场景等价于 --folder
|
||
(当 nodeId 实际指向文件夹节点时, 直觉式调用 --node <FOLDER_NODE_ID> 也可正常工作; 推荐用 --folder 表意更清晰)。`,
|
||
Example: ` dws doc list
|
||
dws doc list --folder DOC_FOLDER_NODE_ID
|
||
dws doc list --workspace WS_ID --limit 20`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
toolArgs := map[string]any{}
|
||
if folder := docFolderFlag(cmd, "node", "file-id"); folder != "" {
|
||
if err := validateDocFolderID(folder); err != nil {
|
||
return err
|
||
}
|
||
toolArgs["folderId"] = folder
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||
toolArgs["pageSize"] = v
|
||
} else if v, _ := cmd.Flags().GetInt("page-size"); v > 0 {
|
||
toolArgs["pageSize"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "cursor", "page-token"); v != "" {
|
||
toolArgs["pageToken"] = v
|
||
}
|
||
return callMCPTool("list_nodes", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(listCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "list_nodes",
|
||
CanonicalPath: "doc.list_nodes",
|
||
CLIPath: "doc list",
|
||
PrimaryCLIPath: "doc list",
|
||
},
|
||
Description: "兼容入口:遍历文件夹或知识库的直接子节点;目录浏览能力已迁移到 drive/wiki。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "list_nodes"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:遍历文件夹或知识库的直接子节点;目录浏览能力已迁移到 drive/wiki。",
|
||
UseWhen: []string{"兼容入口:遍历文件夹或知识库直接子节点时(已弃用,日常改用 drive list / wiki node list)"},
|
||
AvoidWhen: []string{"日常浏览「我的文档」/钉盘用 dws drive list;知识库用 dws wiki node list"},
|
||
Examples: []string{
|
||
"dws doc list --format json",
|
||
"dws doc list --folder <FOLDER_ID> --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "cursor", Property: "pageToken"},
|
||
{Name: "folder", Property: "folderId"},
|
||
{Name: "limit", Property: "pageSize"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
infoCmd := &cobra.Command{
|
||
Use: "info",
|
||
Short: "获取文档元信息",
|
||
Long: `获取文档标题、类型、创建者、创建时间、权限等元信息 (不含内容)。`,
|
||
Example: ` dws doc info --node DOC_ID
|
||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("get_document_info", map[string]any{"nodeId": nodeID})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(infoCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "get_document_info",
|
||
CanonicalPath: "doc.get_document_info",
|
||
CLIPath: "doc info",
|
||
PrimaryCLIPath: "doc info",
|
||
},
|
||
Description: "获取文档元信息(标题/类型/创建者/权限等)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "get_document_info"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取文档元信息(标题/类型/创建者/权限等)",
|
||
UseWhen: []string{
|
||
"用户要查看文档/节点元信息(标题、类型、创建者、权限)时",
|
||
"准备读内容前必须先看 contentType/extension 以路由到 read/sheet/aitable/download 时",
|
||
},
|
||
AvoidWhen: []string{
|
||
"已确认是 adoc 且只要正文改用 dws doc read",
|
||
"只要目录列表改用 dws drive list / wiki node list",
|
||
"需要可靠文件大小 fileSize 时改用 dws drive info;文档元信息接口可能不返回大小",
|
||
},
|
||
Examples: []string{
|
||
"dws doc info --node <DOC_ID> --format json",
|
||
"dws doc info --node \"https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>\" --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
readCmd := &cobra.Command{
|
||
Use: "read",
|
||
Short: "读取文档内容 (Markdown)",
|
||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
|
||
Example: ` dws doc read --node DOC_ID
|
||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateDocFormat(cmd, []string{"", "markdown", "jsonml"}, "doc read",
|
||
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
|
||
return err
|
||
}
|
||
format, _ := cmd.Flags().GetString("content-format")
|
||
scope, _ := cmd.Flags().GetString("scope")
|
||
tags, _ := cmd.Flags().GetString("tags")
|
||
startBlockID, _ := cmd.Flags().GetString("start-block-id")
|
||
endBlockID, _ := cmd.Flags().GetString("end-block-id")
|
||
if scope != "" || tags != "" {
|
||
if format != "jsonml" {
|
||
return fmt.Errorf("--scope/--tags requires --content-format jsonml")
|
||
}
|
||
if scope == "" {
|
||
return fmt.Errorf("--tags requires --scope tags")
|
||
}
|
||
switch scope {
|
||
case "outline", "range", "section", "tags":
|
||
default:
|
||
return fmt.Errorf("invalid --scope %q: must be one of outline|range|section|tags", scope)
|
||
}
|
||
if tags != "" && scope != "tags" {
|
||
return fmt.Errorf("--tags only works with --scope tags")
|
||
}
|
||
if scope == "tags" && tags == "" {
|
||
return fmt.Errorf("--tags is required when --scope=tags")
|
||
}
|
||
if (scope == "range" || scope == "section") && startBlockID == "" {
|
||
return fmt.Errorf("--start-block-id is required when --scope=%s", scope)
|
||
}
|
||
if endBlockID != "" && scope != "range" {
|
||
return fmt.Errorf("--end-block-id only works with --scope=range")
|
||
}
|
||
maxDepth, _ := cmd.Flags().GetInt("max-depth")
|
||
outputPath, _ := cmd.Flags().GetString("output")
|
||
return runDocReadScope(
|
||
nodeID,
|
||
scope,
|
||
tags,
|
||
maxDepth,
|
||
cmd.Flags().Changed("max-depth"),
|
||
startBlockID,
|
||
endBlockID,
|
||
outputPath,
|
||
)
|
||
}
|
||
if format == "jsonml" {
|
||
outputPath, _ := cmd.Flags().GetString("output")
|
||
return runDocReadJsonML(cmd, nodeID, outputPath)
|
||
}
|
||
return callMCPTool("get_document_content", map[string]any{
|
||
"nodeId": nodeID,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(readCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "get_document_content",
|
||
CanonicalPath: "doc.get_document_content",
|
||
CLIPath: "doc read",
|
||
PrimaryCLIPath: "doc read",
|
||
},
|
||
Description: "读取完整文档内容,或按 outline/range/section/tags 获取 JSONML fragment",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "get_document_content"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "读取完整文档内容,或按 outline/range/section/tags 获取 JSONML fragment",
|
||
UseWhen: []string{
|
||
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
|
||
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
|
||
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
|
||
},
|
||
AvoidWhen: []string{
|
||
"非 adoc(表格/多维表/普通文件)不要用本命令;先 doc info 再路由",
|
||
"要元信息用 doc info;要块结构用 doc block list",
|
||
"Markdown 为有损投影:保形复制模板请用 doc copy,不要 read→create",
|
||
},
|
||
Examples: []string{
|
||
"dws doc read --node <DOC_ID> --format json",
|
||
"dws doc read --node <DOC_ID> --content-format jsonml --scope outline --max-depth 3",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||
{Name: "content-format", Property: "format", Required: boolPtr(false)},
|
||
{Name: "end-block-id", Required: boolPtr(false)},
|
||
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
|
||
{Name: "scope", Required: boolPtr(false)},
|
||
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
|
||
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
|
||
},
|
||
},
|
||
})
|
||
|
||
createCmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: "创建文档",
|
||
Long: `创建一篇新的钉钉在线文档。
|
||
创建位置优先级: --folder > --workspace > 默认 (我的文档根目录)
|
||
|
||
初始内容来源(--content-file 优先于 --content):
|
||
--content "..." 短文本字面量(仅推荐 <2KB 且无换行/表格)
|
||
--content - 从 stdin 读取(可配合 heredoc/pipe)
|
||
--content-file path 从 UTF-8 文件读取(推荐长/多行/表格内容,避免 shell escape)`,
|
||
Example: ` dws doc create --name "项目周报"
|
||
dws doc create --name "Q1 总结" --content "# Q1 总结" --folder DOC_FOLDER_NODE_ID
|
||
dws doc create --name "知识库文档" --workspace WS_ID
|
||
dws doc create --name "周报" --content-file ./weekly.md --folder DOC_FOLDER_NODE_ID
|
||
cat report.md | dws doc create --name "月报" --content -`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateDocFormat(cmd, []string{"", "markdown", "jsonml"}, "doc create",
|
||
"dws doc create --name \"demo\" --content-format jsonml --content-file body.json"); err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlagWithAliases(cmd, "name", "title"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"name": flagOrFallback(cmd, "name", "title"),
|
||
}
|
||
if v := docFolderFlag(cmd); v != "" {
|
||
if err := validateDocFolderID(v); err != nil {
|
||
return err
|
||
}
|
||
toolArgs["folderId"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
md, err := resolveContentFromFlags(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
format, _ := cmd.Flags().GetString("content-format")
|
||
if format != "jsonml" && md != "" && sniffJsonMLLike(md) {
|
||
deps.Out.PrintWarning(`输入内容看起来是 JSONML 结构(首字符 '[' 后紧跟 JSON 字符串)。`)
|
||
deps.Out.PrintWarning(`若要按 JSONML 解析,请加 --content-format jsonml;否则将按 markdown 解析。`)
|
||
}
|
||
if format == "jsonml" && md != "" {
|
||
jsonmlStr, err := prepareJsonMLBody(cmd, md)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
createArgs := map[string]any{"name": toolArgs["name"]}
|
||
if v, ok := toolArgs["folderId"]; ok {
|
||
createArgs["folderId"] = v
|
||
}
|
||
if v, ok := toolArgs["workspaceId"]; ok {
|
||
createArgs["workspaceId"] = v
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer cancel()
|
||
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
newNodeID := extractNodeIDFromResult(resultText)
|
||
if newNodeID == "" {
|
||
return fmt.Errorf("创建文档成功但无法提取 nodeId")
|
||
}
|
||
return callMCPTool("update_document", map[string]any{
|
||
"nodeId": newNodeID,
|
||
"format": "jsonml",
|
||
"jsonml": jsonmlStr,
|
||
"mode": "overwrite",
|
||
})
|
||
}
|
||
if md != "" {
|
||
if name, ok := toolArgs["name"].(string); ok && name != "" {
|
||
md = stripDuplicateTitle(md, name)
|
||
}
|
||
toolArgs["markdown"] = md
|
||
}
|
||
if md != "" {
|
||
return docWritePipeline(cmd, "create_document", toolArgs, md, "doc create")
|
||
}
|
||
return callMCPTool("create_document", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(createCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "create_document",
|
||
CanonicalPath: "doc.create_document",
|
||
CLIPath: "doc create",
|
||
PrimaryCLIPath: "doc create",
|
||
},
|
||
Description: "创建一篇新的在线文档",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "create_document"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "创建一篇新的在线文档",
|
||
UseWhen: []string{
|
||
"用户要新建一篇文字在线文档(adoc),可空文档或带初始 Markdown 时",
|
||
"创建到指定文件夹 --folder、知识库根 --workspace,或默认「我的文档」根目录时",
|
||
},
|
||
AvoidWhen: []string{
|
||
"创建表格/脑图/白板/多维表/演示改用 dws wiki node create --type <type>(勿用 doc create)",
|
||
"在知识库建空节点实体也可用 wiki node create;本命令侧重可写初始内容的 adoc",
|
||
"导入本地 Word/Markdown 为在线文档改用 dws doc import(若可用)或 upload --convert",
|
||
},
|
||
Examples: []string{
|
||
"dws doc create --name \"项目周报\" --format json",
|
||
"dws doc create --name \"Q1 总结\" --content \"# Q1 总结\" --folder <FOLDER_ID> --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "name", Required: boolPtr(true)},
|
||
{Name: "folder", Property: "folderId"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
updateCmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: "更新文档内容",
|
||
Long: `更新文档的 Markdown 内容。
|
||
--mode overwrite: 覆盖 (清空原内容后重写)
|
||
--mode append: 追加 (在末尾追加,最安全)
|
||
注意: --mode 为必填参数,必须显式指定 overwrite 或 append。
|
||
|
||
WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||
- 必须显式传 --yes 才会执行覆盖;不传 --yes 时命令直接返回错误。
|
||
- 可先用 --dry-run 预览待写入内容与当前内容的差异 (不调远端 update)。
|
||
- 调用前建议先用 dws doc read 备份现状;调用后建议再次 read 校验。
|
||
|
||
内容来源(--content-file 优先于 --content):
|
||
--content "..." 短文本字面量(仅推荐 <2KB 且无换行/表格)
|
||
--content - 从 stdin 读取(可配合 heredoc/pipe)
|
||
--content-file path 从 UTF-8 文件读取(推荐长/多行/表格内容,避免 shell escape)
|
||
|
||
插入位置(仅 mode=append 生效):
|
||
--index N 将内容插入到文档第 N 个 block 之前(从 0 开始)。
|
||
不传时追加到末尾。block index 可通过 doc block list 获取。
|
||
插入成功后,该位置及之后所有 block 的 index 会依次 +1。`,
|
||
Example: ` dws doc update --node DOC_ID --content "# 新内容" --mode append
|
||
dws doc update --node DOC_ID --content "# 完整替换" --mode overwrite --yes
|
||
dws doc update --node DOC_ID --content-file ./part1.md --mode overwrite --dry-run
|
||
dws doc update --node DOC_ID --content-file ./part1.md --mode append
|
||
dws doc update --node DOC_ID --content "# 插入到第3个block前" --mode append --index 2
|
||
dws doc update --node DOC_ID --content-file ./body.json --content-format jsonml --revision 42 --mode overwrite
|
||
cat part2.md | dws doc update --node DOC_ID --content - --mode append`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateDocFormat(cmd, []string{"", "markdown", "jsonml"}, "doc update",
|
||
"dws doc update --node DOC_ID --content-format jsonml --content-file body.json --mode overwrite --yes"); err != nil {
|
||
return err
|
||
}
|
||
md, err := resolveContentFromFlags(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if md == "" {
|
||
return fmt.Errorf("必须通过 --content 或 --content-file 提供内容")
|
||
}
|
||
mode, _ := cmd.Flags().GetString("mode")
|
||
if mode == "" {
|
||
return fmt.Errorf("必须通过 --mode 指定更新模式(overwrite 或 append)")
|
||
}
|
||
yes, _ := cmd.Flags().GetBool("yes")
|
||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||
if mode == "overwrite" {
|
||
if dryRun {
|
||
return previewDocOverwriteDiff(cmd.Context(), cmd, nodeID, md)
|
||
}
|
||
if !yes {
|
||
return fmt.Errorf("--mode overwrite 为破坏性写入,请加 --yes 显式确认,或加 --dry-run 预览差异 (不调远端)")
|
||
}
|
||
}
|
||
format, _ := cmd.Flags().GetString("content-format")
|
||
if format != "jsonml" && md != "" && sniffJsonMLLike(md) {
|
||
deps.Out.PrintWarning(`输入内容看起来是 JSONML 结构(首字符 '[' 后紧跟 JSON 字符串)。`)
|
||
deps.Out.PrintWarning(`若要按 JSONML 解析,请加 --content-format jsonml;否则将按 markdown 解析。`)
|
||
}
|
||
if format == "jsonml" {
|
||
if mode == "append" {
|
||
return fmt.Errorf("--content-format jsonml 当前仅支持 --mode overwrite,append 模式将在后续版本支持")
|
||
}
|
||
jsonmlStr, err := prepareJsonMLBody(cmd, md)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
updateArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"format": "jsonml",
|
||
"jsonml": jsonmlStr,
|
||
"mode": mode,
|
||
}
|
||
if rev, _ := cmd.Flags().GetInt("revision"); cmd.Flags().Lookup("revision").Changed {
|
||
updateArgs["revision"] = rev
|
||
}
|
||
return callMCPTool("update_document", updateArgs)
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"markdown": md,
|
||
"mode": mode,
|
||
}
|
||
if idx, _ := cmd.Flags().GetInt("index"); cmd.Flags().Lookup("index").Changed {
|
||
toolArgs["index"] = idx
|
||
}
|
||
return docWritePipeline(cmd, "update_document", toolArgs, md, "doc update")
|
||
},
|
||
}
|
||
DeclareLeafMetadata(updateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "update_document",
|
||
CanonicalPath: "doc.update_document",
|
||
CLIPath: "doc update",
|
||
PrimaryCLIPath: "doc update",
|
||
},
|
||
Description: "更新文档内容(追加 / 覆盖;覆盖需 --yes)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "update_document"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "更新文档内容(追加 / 覆盖;覆盖需 --yes)",
|
||
UseWhen: []string{
|
||
"用户要向已有 adoc 追加内容时用 --mode append(更安全)",
|
||
"用户明确要求整篇覆盖替换时用 --mode overwrite(破坏性)",
|
||
"append 且要插到第 N 个 block 前时加 --index N(先 block list)",
|
||
},
|
||
AvoidWhen: []string{
|
||
"目标不是 adoc 或只要改单个块时改用 doc block update",
|
||
"覆盖模式用户未确认前不要执行;可先 --dry-run 预览",
|
||
"创建新文档用 doc create,不要用 update 冒充创建",
|
||
},
|
||
Examples: []string{
|
||
"dws doc update --node <DOC_ID> --content \"# 追加内容\" --mode append --format json",
|
||
"dws doc update --node <DOC_ID> --content-file ./body.md --mode overwrite --dry-run",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||
{Name: "content-format", Property: "format"},
|
||
},
|
||
},
|
||
})
|
||
|
||
fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}
|
||
|
||
fileCreateCmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: "创建文件",
|
||
Long: `在指定目录下新增文件。支持的文件类型 (--type):
|
||
adoc 钉钉在线文档
|
||
axls 钉钉表格
|
||
appt 钉钉演示
|
||
adraw 钉钉白板
|
||
amind 钉钉脑图
|
||
able 钉钉多维表
|
||
folder 文件夹
|
||
|
||
兼容旧版 accessType: "0"=adoc "1"=axls "2"=appt "3"=adraw "6"=amind "7"=able "13"=folder
|
||
创建位置优先级: --folder > --workspace > 默认 (我的文档根目录)`,
|
||
Example: ` dws doc file create --name "项目周报" --type adoc
|
||
dws doc file create --name "数据统计" --type axls --folder DOC_FOLDER_NODE_ID
|
||
dws doc file create --name "思维导图" --type amind --workspace WS_ID
|
||
dws doc file create --name "子文件夹" --type folder`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlagWithAliases(cmd, "name", "title"); err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "type"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"name": flagOrFallback(cmd, "name", "title"),
|
||
"type": mustGetFlag(cmd, "type"),
|
||
}
|
||
if v := docFolderFlag(cmd); v != "" {
|
||
if err := validateDocFolderID(v); err != nil {
|
||
return err
|
||
}
|
||
toolArgs["folderId"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool("create_file", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(fileCreateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "create_file",
|
||
CanonicalPath: "doc.create_file",
|
||
CLIPath: "doc file create",
|
||
PrimaryCLIPath: "doc file create",
|
||
},
|
||
Description: "创建文件(文档/表格/脑图/白板/多维表/文件夹等)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "create_file"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "创建文件(文档/表格/脑图/白板/多维表/文件夹等)",
|
||
UseWhen: []string{"兼容入口:按类型创建文件节点时(已弃用,改用 wiki node create)"},
|
||
AvoidWhen: []string{
|
||
"请改用 dws wiki node create --workspace <id> --type <type>",
|
||
"纯文字带内容创建用 doc create",
|
||
},
|
||
Examples: []string{"dws doc file create --name \"项目周报\" --type adoc --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "folder", Property: "folderId"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}
|
||
|
||
folderCreateCmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: "创建文件夹",
|
||
Long: `在指定位置创建文件夹。
|
||
创建位置优先级: --folder (父文件夹) > --workspace > 默认 (我的文档根目录)`,
|
||
Example: ` dws doc folder create --name "项目资料"
|
||
dws doc folder create --name "子文件夹" --folder PARENT_DOC_FOLDER_NODE_ID`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlagWithAliases(cmd, "name", "title"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"name": flagOrFallback(cmd, "name", "title"),
|
||
}
|
||
if v := docFolderFlag(cmd); v != "" {
|
||
if err := validateDocFolderID(v); err != nil {
|
||
return err
|
||
}
|
||
toolArgs["folderId"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool("create_folder", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(folderCreateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "create_folder",
|
||
CanonicalPath: "doc.create_folder",
|
||
CLIPath: "doc folder create",
|
||
PrimaryCLIPath: "doc folder create",
|
||
},
|
||
Description: "创建文件夹",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "create_folder"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "创建文件夹",
|
||
UseWhen: []string{"兼容入口:创建文件夹(已弃用)时"},
|
||
AvoidWhen: []string{"个人空间/钉盘改用 dws drive mkdir;知识库改用 wiki node create --type folder"},
|
||
Examples: []string{"dws doc folder create --name \"项目资料\" --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "folder", Property: "folderId"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
uploadCmd := &cobra.Command{
|
||
Use: "upload",
|
||
Short: "上传文件到钉钉文档或钉钉知识库",
|
||
Long: `将本地文件上传到钉钉文档或钉钉知识库(三步上传流程)。
|
||
|
||
流程:
|
||
1. 获取 OSS 上传凭证 (get_file_upload_info)
|
||
2. HTTP PUT 上传文件二进制到 OSS
|
||
3. 提交文件入库 (commit_uploaded_file)
|
||
|
||
上传位置优先级: --folder > --workspace > 默认 (我的文档根目录)`,
|
||
Example: ` dws doc upload --file ./report.pdf
|
||
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder DOC_FOLDER_NODE_ID
|
||
dws doc upload --file ./data.xlsx --workspace WS_ID --convert`,
|
||
RunE: runDocUpload,
|
||
}
|
||
DeclareLeafMetadata(uploadCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "upload",
|
||
CanonicalPath: "doc.upload",
|
||
CLIPath: "doc upload",
|
||
PrimaryCLIPath: "doc upload",
|
||
},
|
||
Description: "兼容入口:上传本地文件到钉盘或文档空间;文件上传能力已迁移到 drive。",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:上传本地文件到钉盘或文档空间;文件上传能力已迁移到 drive。",
|
||
UseWhen: []string{"把本地文件上传到文档空间/知识库(可 --convert 转在线文档)时"},
|
||
AvoidWhen: []string{
|
||
"钉盘/我的文件上传优先 dws drive upload",
|
||
"插入文档正文附件用 media insert",
|
||
},
|
||
Examples: []string{
|
||
"dws doc upload --file ./report.pdf --format json",
|
||
"dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert --format json",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
downloadCmd := &cobra.Command{
|
||
Use: "download",
|
||
Short: "下载文件",
|
||
Long: `下载钉钉文档空间中的文件到本地(两步下载流程)。
|
||
|
||
流程:
|
||
1. 获取下载 URL 和签名请求头 (download_file)
|
||
2. HTTP GET 下载文件二进制内容到本地`,
|
||
Example: ` dws doc download --node NODE_ID --output ./download.bin
|
||
dws doc download --node NODE_ID --output ./report.pdf
|
||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ~/downloads/`,
|
||
RunE: runDocDownload,
|
||
}
|
||
DeclareLeafMetadata(downloadCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "download_file",
|
||
CanonicalPath: "doc.download_file",
|
||
CLIPath: "doc download",
|
||
PrimaryCLIPath: "doc download",
|
||
},
|
||
Description: "兼容入口:下载钉盘或文档空间已有文件;文件下载能力已迁移到 drive。",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "download_file"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:下载钉盘或文档空间已有文件;文件下载能力已迁移到 drive。",
|
||
UseWhen: []string{"兼容入口:获取文件下载凭证(已迁移场景优先 drive download)时"},
|
||
AvoidWhen: []string{
|
||
"常规下载普通文件改用 dws drive download --output ...",
|
||
"在线文档导出 docx 用 doc export",
|
||
},
|
||
Examples: []string{"dws doc download --node <NODE_ID> --output ./report.pdf --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
blockCmd := &cobra.Command{
|
||
Use: "block",
|
||
Short: "块级编辑",
|
||
Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
blockListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "查询块元素",
|
||
Long: `查询文档的一级块元素列表,支持按位置范围和块类型过滤。`,
|
||
Example: ` dws doc block list --node DOC_ID
|
||
dws doc block list --node DOC_ID --start-index 0 --end-index 5
|
||
dws doc block list --node DOC_ID --block-type heading`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateDocFormat(cmd, []string{"", "element", "jsonml"}, "doc block list",
|
||
"dws doc block list --node DOC_ID --content-format jsonml"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("start-index"); cmd.Flags().Changed("start-index") {
|
||
toolArgs["startIndex"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("end-index"); cmd.Flags().Changed("end-index") {
|
||
toolArgs["endIndex"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("block-type"); v != "" {
|
||
toolArgs["blockType"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("content-format"); v != "" {
|
||
toolArgs["format"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("block-id"); v != "" {
|
||
toolArgs["blockId"] = v
|
||
}
|
||
return callMCPTool("list_document_blocks", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(blockListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "list_document_blocks",
|
||
CanonicalPath: "doc.list_document_blocks",
|
||
CLIPath: "doc block list",
|
||
PrimaryCLIPath: "doc block list",
|
||
},
|
||
Description: "查询文档一级块元素列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "list_document_blocks"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询文档一级块元素列表",
|
||
UseWhen: []string{"查看文档一级块结构、拿 blockId,供 insert/update/delete 或划词评论定位时"},
|
||
AvoidWhen: []string{
|
||
"只要全文 Markdown 用 doc read",
|
||
"要改内容分别用 block insert/update/delete",
|
||
},
|
||
Examples: []string{
|
||
"dws doc block list --node <DOC_ID> --format json",
|
||
"dws doc block list --node <DOC_ID> --start-index 0 --end-index 5 --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "content-format", Property: "format"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
blockInsertCmd := &cobra.Command{
|
||
Use: "insert",
|
||
Short: "插入块元素",
|
||
Long: `向文档插入块元素。通过 --element 传入 JSON 格式的块元素。
|
||
可用 --content 快速插入段落,--heading + --level 快速插入标题。
|
||
|
||
块类型: paragraph, heading, blockquote, callout, columns,
|
||
orderedList, unorderedList, table, sheet, attachment, slot`,
|
||
Example: ` # 快捷插入段落
|
||
dws doc block insert --node DOC_ID --content "这是一段文字"
|
||
|
||
# 快捷插入标题
|
||
dws doc block insert --node DOC_ID --heading "二级标题" --level 2
|
||
|
||
# 高级: 用 JSON 插入任意块
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
|
||
|
||
# 插入分栏块(columns):2 栏,children 为每栏内容
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏内容"}},{"blockType":"paragraph","paragraph":{"text":"右栏内容"}}]}'
|
||
|
||
# 插入附件块(attachment):resourceId 通过 media insert 上传后获得
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"attachment","attachment":{"resourceId":"<RESOURCE_ID>","type":"application/pdf","name":"报告.pdf","viewType":"preview"}}'
|
||
|
||
# 插入有序列表(orderedList):同一列表每次只插入一个 item,listId 相同则属于同一列表
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"orderedList","orderedList":{"list":{"listId":"list-1"}},"children":[{"text":"第一项"}]}'
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"orderedList","orderedList":{"list":{"listId":"list-1"}},"children":[{"text":"第二项"}]}'
|
||
|
||
# 插入无序列表(unorderedList):同一列表每次只插入一个 item,listId 相同则属于同一列表
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第一项"}]}'
|
||
dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第二项"}]}'
|
||
|
||
# 在指定位置之前插入
|
||
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 {
|
||
return err
|
||
}
|
||
if err := validateDocFormat(cmd, []string{"", "element", "jsonml"}, "doc block insert",
|
||
"dws doc block insert --node DOC_ID --content-format jsonml --element '[\"p\",{},\"hello\"]'"); err != nil {
|
||
return err
|
||
}
|
||
format, _ := cmd.Flags().GetString("content-format")
|
||
if format != "jsonml" {
|
||
if el, _ := cmd.Flags().GetString("element"); el != "" && sniffJsonMLLike(el) {
|
||
deps.Out.PrintWarning(`--element 内容看起来是 JSONML 结构(首字符 '[' 后紧跟 JSON 字符串)。`)
|
||
deps.Out.PrintWarning(`若要按 JSONML 解析,请加 --content-format jsonml;否则将按 element 解析。`)
|
||
}
|
||
}
|
||
if format == "jsonml" {
|
||
elementStr := mustGetFlag(cmd, "element")
|
||
normalized, err := prepareJsonMLNode(cmd, elementStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"jsonml": normalized,
|
||
"format": "jsonml",
|
||
}
|
||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||
toolArgs["referenceBlockId"] = v
|
||
where, _ := cmd.Flags().GetString("where")
|
||
if where == "" {
|
||
where = "after"
|
||
}
|
||
toolArgs["where"] = where
|
||
}
|
||
if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
|
||
toolArgs["referenceBlockId"] = v
|
||
}
|
||
if cmd.Flags().Changed("index") {
|
||
idx, _ := cmd.Flags().GetInt("index")
|
||
toolArgs["index"] = idx
|
||
}
|
||
return callMCPTool("insert_document_block", toolArgs)
|
||
}
|
||
element, err := buildBlockElement(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"element": element,
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("index"); cmd.Flags().Changed("index") {
|
||
toolArgs["index"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("where"); v != "" {
|
||
toolArgs["where"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||
toolArgs["referenceBlockId"] = v
|
||
}
|
||
return callMCPTool("insert_document_block", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(blockInsertCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "insert_document_block",
|
||
CanonicalPath: "doc.insert_document_block",
|
||
CLIPath: "doc block insert",
|
||
PrimaryCLIPath: "doc block insert",
|
||
},
|
||
Description: "向文档插入块元素",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "insert_document_block"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "向文档插入块元素",
|
||
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> --content \"这是一段文字\" --format json",
|
||
"dws doc block insert --node <DOC_ID> --heading \"二级标题\" --level 2 --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "content-format", Property: "format"},
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "parent-block", Property: "referenceBlockId"},
|
||
{Name: "ref-block", Property: "referenceBlockId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
blockUpdateCmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: "更新块元素",
|
||
Long: `更新文档中指定块的内容或样式。需提供 --block-id 和块元素内容。
|
||
可用 --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 {
|
||
return err
|
||
}
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
blockID := mustGetFlag(cmd, "block-id")
|
||
if err := validateDocFormat(cmd, []string{"", "element", "jsonml"}, "doc block update",
|
||
"dws doc block update --node DOC_ID --block-id BLOCK_ID --content-format jsonml --element '[\"p\",{},\"new\"]'"); err != nil {
|
||
return err
|
||
}
|
||
format, _ := cmd.Flags().GetString("content-format")
|
||
if format != "jsonml" {
|
||
if el, _ := cmd.Flags().GetString("element"); el != "" && sniffJsonMLLike(el) {
|
||
deps.Out.PrintWarning(`--element 内容看起来是 JSONML 结构(首字符 '[' 后紧跟 JSON 字符串)。`)
|
||
deps.Out.PrintWarning(`若要按 JSONML 解析,请加 --content-format jsonml;否则将按 element 解析。`)
|
||
}
|
||
}
|
||
if format == "jsonml" {
|
||
elementStr := mustGetFlag(cmd, "element")
|
||
normalized, err := prepareJsonMLNode(cmd, elementStr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("update_document_block", map[string]any{
|
||
"nodeId": nodeID,
|
||
"blockId": blockID,
|
||
"jsonml": normalized,
|
||
"format": "jsonml",
|
||
})
|
||
}
|
||
element, err := buildBlockElement(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("update_document_block", map[string]any{
|
||
"nodeId": nodeID,
|
||
"blockId": blockID,
|
||
"element": element,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(blockUpdateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "update_document_block",
|
||
CanonicalPath: "doc.update_document_block",
|
||
CLIPath: "doc block update",
|
||
PrimaryCLIPath: "doc block update",
|
||
},
|
||
Description: "更新文档中的指定块",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "update_document_block"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "更新文档中的指定块",
|
||
UseWhen: []string{"修改已有块的文本/标题/样式(已知 blockId)时"},
|
||
AvoidWhen: []string{
|
||
"插入新块用 block insert;删除用 block delete",
|
||
"改文档显示名用 rename;整篇覆盖用 update overwrite",
|
||
},
|
||
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content \"新内容\" --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "content-format", Property: "format"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
blockDeleteCmd := &cobra.Command{
|
||
Use: "delete",
|
||
Short: "删除块元素",
|
||
Example: ` dws doc block delete --node DOC_ID --block-id BLOCK_ID --yes # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlags(cmd, "block-id"); err != nil {
|
||
return err
|
||
}
|
||
blockID := mustGetFlag(cmd, "block-id")
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("delete_document_block", map[string]any{
|
||
"nodeId": nodeID,
|
||
"blockId": blockID,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(blockDeleteCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "destructive", Risk: "high",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "delete_document_block",
|
||
CanonicalPath: "doc.delete_document_block",
|
||
CLIPath: "doc block delete",
|
||
PrimaryCLIPath: "doc block delete",
|
||
},
|
||
Description: "删除块元素(不可逆)",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "delete_document_block"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "删除块元素(不可逆)",
|
||
UseWhen: []string{"用户确认后删除文档中指定块元素时"},
|
||
AvoidWhen: []string{
|
||
"未确认或 blockId 不明时不要删;先 block list",
|
||
"删整篇文档用 doc/drive delete",
|
||
},
|
||
Examples: []string{"dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
copyCmd := &cobra.Command{
|
||
Use: "copy",
|
||
Short: "复制文档/文件到指定位置",
|
||
Long: `将文档或文件复制到指定文件夹或知识库。
|
||
--folder 指定目标文档文件夹 nodeId 或 alidocs 文件夹 URL,--workspace 指定目标知识库 ID。
|
||
不要把 drive/chat 链路返回的纯数字 dentryId/parent-id 传给 --folder。
|
||
不传 --folder 时复制到 --workspace 知识库根目录;都不传则默认到"我的文档"。
|
||
|
||
权限要求: 对源文档有"阅读"权限,且对目标文件夹有"编辑"权限。`,
|
||
Example: ` dws doc copy --node DOC_ID --folder TARGET_DOC_FOLDER_NODE_ID
|
||
dws doc copy --node DOC_ID --workspace TARGET_WS_ID
|
||
dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --folder DOC_FOLDER_NODE_ID`,
|
||
RunE: buildNodeTransferRunE("copy_document"),
|
||
}
|
||
DeclareLeafMetadata(copyCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "copy_document",
|
||
CanonicalPath: "doc.copy_document",
|
||
CLIPath: "doc copy",
|
||
PrimaryCLIPath: "doc copy",
|
||
},
|
||
Description: "兼容入口:复制文档或文件;文件复制能力已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "copy_document"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:复制文档或文件;文件复制能力已迁移到 drive。",
|
||
UseWhen: []string{
|
||
"用户要复制文档/文件并保留原位置时(尤其保形复制模板:copy + rename + block update)",
|
||
"目标文件夹 --folder 或知识库根 --workspace;都不传则默认「我的文档」",
|
||
},
|
||
AvoidWhen: []string{
|
||
"要搬走不留副本改用 dws doc move 或 dws drive move",
|
||
"跨钉盘整理文件优先 dws drive copy",
|
||
},
|
||
Examples: []string{
|
||
"dws doc copy --node <DOC_ID> --folder <TARGET_FOLDER_ID> --format json",
|
||
"dws doc copy --node <DOC_ID> --workspace <TARGET_WS_ID> --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "folder", Property: "targetFolderId"},
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
moveCmd := &cobra.Command{
|
||
Use: "move",
|
||
Short: "移动文档/文件到指定位置",
|
||
Long: `将文档或文件移动到指定文件夹或知识库。移动后原位置的文档将不再存在。
|
||
--folder 指定目标文档文件夹 nodeId 或 alidocs 文件夹 URL,--workspace 指定目标知识库 ID。
|
||
不要把 drive/chat 链路返回的纯数字 dentryId/parent-id 传给 --folder。
|
||
不传 --folder 时移动到 --workspace 知识库根目录;都不传则默认到"我的文档"。
|
||
|
||
权限要求: 对源文档有"管理"权限,且对目标文件夹有"编辑"权限。`,
|
||
Example: ` dws doc move --node DOC_ID --folder TARGET_DOC_FOLDER_NODE_ID
|
||
dws doc move --node DOC_ID --workspace TARGET_WS_ID
|
||
dws doc move --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --folder DOC_FOLDER_NODE_ID`,
|
||
RunE: buildNodeTransferRunE("move_document"),
|
||
}
|
||
DeclareLeafMetadata(moveCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "move_document",
|
||
CanonicalPath: "doc.move_document",
|
||
CLIPath: "doc move",
|
||
PrimaryCLIPath: "doc move",
|
||
},
|
||
Description: "兼容入口:移动文档或文件;文件移动能力已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "move_document"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:移动文档或文件;文件移动能力已迁移到 drive。",
|
||
UseWhen: []string{"用户要移动文档/文件且原位置不再保留时"},
|
||
AvoidWhen: []string{
|
||
"要保留副本改用 doc/drive copy",
|
||
"目标未确认时不要 move",
|
||
},
|
||
Examples: []string{
|
||
"dws doc move --node <DOC_ID> --folder <TARGET_FOLDER_ID> --format json",
|
||
"dws doc move --node <DOC_ID> --workspace <TARGET_WS_ID> --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "folder", Property: "targetFolderId"},
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
renameCmd := &cobra.Command{
|
||
Use: "rename",
|
||
Short: "重命名文档/文件",
|
||
Long: `修改文档或文件的名称。用户说重命名、rename、改名、修改文档名称/标题时走 doc rename。
|
||
不要用 doc update 修改列表/链接展示名称,也不要重新 create。
|
||
|
||
权限要求: 对文档有"编辑"权限。`,
|
||
Example: ` dws doc rename --node DOC_ID --name "新名称"
|
||
dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --name "项目周报 v2"`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
if err := validateRequiredFlagWithAliases(cmd, "name", "title"); err != nil {
|
||
return err
|
||
}
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("rename_document", map[string]any{
|
||
"nodeId": nodeID,
|
||
"newName": flagOrFallback(cmd, "name", "title"),
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(renameCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "rename_document",
|
||
CanonicalPath: "doc.rename_document",
|
||
CLIPath: "doc rename",
|
||
PrimaryCLIPath: "doc rename",
|
||
},
|
||
Description: "兼容入口:重命名文档或文件;文件重命名能力已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "rename_document"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:重命名文档或文件;文件重命名能力已迁移到 drive。",
|
||
UseWhen: []string{"用户要改在线文档在列表与链接中展示的名称时"},
|
||
AvoidWhen: []string{
|
||
"改正文标题/章节标题改用 doc block update",
|
||
"不要用 update 或重新 create 来改名",
|
||
"文件或文件夹重命名优先用 dws drive rename,由该命令读取真实节点类型和当前扩展名",
|
||
},
|
||
Examples: []string{"dws doc rename --node <DOC_ID> --name \"新名称\" --format json"},
|
||
},
|
||
// No ParamDecl for --name here: the extension-stripping description
|
||
// belongs to drive rename (shared RPC rename_document). doc rename
|
||
// keeps the Cobra usage ("原样传给服务端").
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "name", Property: "newName"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
deleteCmd := &cobra.Command{
|
||
Use: "delete",
|
||
Short: "删除文档/文件到回收站",
|
||
Long: `将文档或文件移入回收站。
|
||
|
||
注意: 这是一个危险操作,文档将被移入回收站。执行前需要确认,或传入 --yes 跳过确认。
|
||
|
||
权限要求: 对文档有"管理"权限。`,
|
||
Example: ` dws doc delete --node DOC_ID --yes # 查询 nodeId: dws doc search --query "..." 或 dws doc list
|
||
dws doc delete --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --yes
|
||
dws doc delete --node DOC_ID # 交互式确认后删除`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPTool("delete_document", map[string]any{
|
||
"nodeId": nodeID,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(deleteCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "destructive", Risk: "high",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "delete_document",
|
||
CanonicalPath: "doc.delete_document",
|
||
CLIPath: "doc delete",
|
||
PrimaryCLIPath: "doc delete",
|
||
},
|
||
Description: "兼容入口:将文档或文件移入回收站;文件删除能力已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "delete_document"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:将文档或文件移入回收站;文件删除能力已迁移到 drive。",
|
||
UseWhen: []string{"用户明确要求用 doc delete 兼容入口将文档/文件移入回收站,且已确认目标时"},
|
||
AvoidWhen: []string{
|
||
"常规文件删除优先 dws drive delete;本入口仅为兼容",
|
||
"用户未确认或目标不清时不要删",
|
||
"删块用 doc block delete;删评论用 doc comment delete",
|
||
},
|
||
Examples: []string{"dws doc delete --node <DOC_ID> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||
},
|
||
},
|
||
})
|
||
|
||
// search
|
||
searchCmd.Flags().String("query", "", "搜索关键词 (不传则返回最近访问)")
|
||
searchCmd.Flags().String("keyword", "", "搜索关键词 (--query 的别名)")
|
||
_ = searchCmd.Flags().MarkHidden("keyword")
|
||
searchCmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,png)。支持的在线文档类型后缀名: adoc=文字, axls=表格, appt=演示文稿, awbd=白板, adraw=画板, amind=脑图, able=多维表格, aform=收集表")
|
||
searchCmd.Flags().Int64("created-from", 0, "创建时间起始 (毫秒时间戳,含)")
|
||
searchCmd.Flags().Int64("created-to", 0, "创建时间截止 (毫秒时间戳,含)")
|
||
searchCmd.Flags().Int64("visited-from", 0, "访问时间起始 (毫秒时间戳,含)")
|
||
searchCmd.Flags().Int64("visited-to", 0, "访问时间截止 (毫秒时间戳,含)")
|
||
searchCmd.Flags().StringSlice("creator-uids", nil, "按创建者用户 ID 过滤,逗号分隔")
|
||
searchCmd.Flags().StringSlice("editor-uids", nil, "按编辑者用户 ID 过滤,逗号分隔")
|
||
searchCmd.Flags().StringSlice("mentioned-uids", nil, "按 @提及的用户 ID 过滤,逗号分隔")
|
||
searchCmd.Flags().StringSlice("workspace-ids", nil, "按知识库 ID 过滤,支持知识库 URL,逗号分隔")
|
||
searchCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 30)")
|
||
searchCmd.Flags().Int("page-size", 0, "")
|
||
_ = searchCmd.Flags().MarkHidden("page-size")
|
||
searchCmd.Flags().String("cursor", "", "分页游标 (从上次结果的 nextPageToken 获取)")
|
||
searchCmd.Flags().String("page-token", "", "")
|
||
_ = searchCmd.Flags().MarkHidden("page-token")
|
||
|
||
// list
|
||
listCmd.Flags().String("folder", "", "文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
listCmd.Flags().String("workspace", "", "知识库 ID")
|
||
listCmd.Flags().Int("limit", 0, "每页数量 (默认 50,最大 50)")
|
||
listCmd.Flags().Int("page-size", 0, "")
|
||
_ = listCmd.Flags().MarkHidden("page-size")
|
||
listCmd.Flags().String("cursor", "", "分页游标 (从上次结果的 nextPageToken 获取)")
|
||
listCmd.Flags().String("page-token", "", "")
|
||
_ = listCmd.Flags().MarkHidden("page-token")
|
||
// ── cross-product hidden aliases for list ──
|
||
listCmd.Flags().String("parent-id", "", "")
|
||
_ = listCmd.Flags().MarkHidden("parent-id")
|
||
// 跨语义组别名: --node / --file-id 在 list 场景等价于 --folder。
|
||
// 这两个 flag 属于 "节点标识" 语义组, 与 "父文件夹" 组独立,
|
||
// 不会被 RegisterCrossProductAliases 自动注册, 故显式注册为 hidden。
|
||
listCmd.Flags().String("node", "", "")
|
||
_ = listCmd.Flags().MarkHidden("node")
|
||
listCmd.Flags().String("file-id", "", "")
|
||
_ = listCmd.Flags().MarkHidden("file-id")
|
||
|
||
// info
|
||
infoCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
|
||
// read
|
||
readCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
readCmd.Flags().String("content-format", "", "输出格式: 默认为 markdown,可选 jsonml")
|
||
readCmd.Flags().String("output", "", "输出到本地文件路径(仅 --content-format jsonml 时生效)")
|
||
readCmd.Flags().String("scope", "", "按 scope 筛选节点(需 --content-format jsonml): outline(全部 h1-h6 标题)/range(区间)/section(单块)/tags(配合 --tags 自定义 tag)")
|
||
readCmd.Flags().String("tags", "", "自定义 JSONML tag 列表(逗号分隔, 如 h1,h2,table); 仅在 --scope tags 时使用且必填")
|
||
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
|
||
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
|
||
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
|
||
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
|
||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
|
||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
|
||
|
||
// create
|
||
createCmd.Flags().String("name", "", "文档名称 (必填)")
|
||
createCmd.Flags().String("folder", "", "目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
createCmd.Flags().String("workspace", "", "目标知识库 ID")
|
||
createCmd.Flags().String("content", "", "文档初始内容(短文本字面量);传 - 表示从 stdin 读取")
|
||
createCmd.Flags().String("content-file", "", "从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用")
|
||
createCmd.Flags().String("markdown", "", "已弃用,请使用 --content 代替")
|
||
_ = createCmd.Flags().MarkHidden("markdown")
|
||
createCmd.Flags().String("content-format", "", "内容格式: 默认为 markdown,可选 jsonml")
|
||
addJsonMLFlags(createCmd)
|
||
|
||
// update
|
||
updateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
updateCmd.Flags().String("content", "", "文档内容(短文本字面量);传 - 表示从 stdin 读取")
|
||
updateCmd.Flags().String("content-file", "", "从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用")
|
||
updateCmd.Flags().String("markdown", "", "已弃用,请使用 --content 代替")
|
||
_ = updateCmd.Flags().MarkHidden("markdown")
|
||
updateCmd.Flags().String("mode", "", "更新模式: overwrite=覆盖, append=追加 (必填)")
|
||
_ = updateCmd.MarkFlagRequired("mode")
|
||
updateCmd.Flags().Int("index", -1, "插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾")
|
||
updateCmd.Flags().Bool("yes", false, "确认执行破坏性写入 (仅 --mode overwrite 需要)")
|
||
updateCmd.Flags().Bool("dry-run", false, "预览覆盖写入差异,不调用远端 update")
|
||
updateCmd.Flags().String("content-format", "", "内容格式: 默认为 markdown,可选 jsonml")
|
||
updateCmd.Flags().Int("revision", 0,
|
||
"可选,文档编辑版本号(仅 --content-format jsonml 生效);"+
|
||
"传则触发并发检查(与服务端不一致时拒绝写入),不传则直接覆盖")
|
||
addJsonMLFlags(updateCmd)
|
||
|
||
fileCreateCmd.Flags().String("name", "", "文件名称 (必填)")
|
||
fileCreateCmd.Flags().String("type", "", "文件类型: adoc/axls/appt/adraw/amind/able/folder (必填)")
|
||
fileCreateCmd.Flags().String("folder", "", "目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
fileCreateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL")
|
||
fileCmd.AddCommand(fileCreateCmd)
|
||
fileCmd.AddCommand(hintSubCmd("search", "use: dws doc search --query <关键词>"))
|
||
|
||
// folder create
|
||
folderCreateCmd.Flags().String("name", "", "文件夹名称 (必填)")
|
||
folderCreateCmd.Flags().String("folder", "", "父文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
folderCreateCmd.Flags().String("workspace", "", "目标知识库 ID")
|
||
folderCmd.AddCommand(folderCreateCmd)
|
||
|
||
// upload
|
||
uploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||
uploadCmd.Flags().String("name", "", "文件显示名称 (默认使用文件名)")
|
||
uploadCmd.Flags().String("folder", "", "目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
uploadCmd.Flags().String("workspace", "", "目标知识库 ID")
|
||
uploadCmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档")
|
||
|
||
// download
|
||
downloadCmd.Flags().String("node", "", "文件节点 ID 或 URL (必填)")
|
||
downloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录)")
|
||
_ = downloadCmd.MarkFlagRequired("node")
|
||
_ = downloadCmd.MarkFlagRequired("output")
|
||
|
||
// block list
|
||
blockListCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
blockListCmd.Flags().Int("start-index", 0, "起始位置 (从 0 开始)")
|
||
blockListCmd.Flags().Int("end-index", 0, "终止位置 (含)")
|
||
blockListCmd.Flags().String("block-type", "", "按块类型过滤")
|
||
|
||
blockListCmd.Flags().String("content-format", "", "输出格式: 默认为 element,可选 jsonml(返回 JSONML 节点数组)")
|
||
blockListCmd.Flags().String("block-id", "", "指定块 UUID(content-format=jsonml 时读取完整子树)")
|
||
|
||
// block insert
|
||
blockInsertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
blockInsertCmd.Flags().String("heading", "", "快捷: 标题文本")
|
||
blockInsertCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
|
||
blockInsertCmd.Flags().String("element", "", "块元素 JSON (高级)")
|
||
blockInsertCmd.Flags().Int("index", 0, "参照位置索引 (从 0 开始)")
|
||
blockInsertCmd.Flags().String("where", "", "插入方向: before / after (默认 after)")
|
||
blockInsertCmd.Flags().String("ref-block", "", "参照块 ID (优先级高于 --index)")
|
||
blockInsertCmd.Flags().String("content-format", "", "输入格式: 默认为 element,可选 jsonml")
|
||
blockInsertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入时使用,与 --index 配合)")
|
||
addJsonMLFlags(blockInsertCmd)
|
||
|
||
// block update
|
||
blockUpdateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
blockUpdateCmd.Flags().String("block-id", "", "目标块 ID (必填)")
|
||
blockUpdateCmd.Flags().String("heading", "", "快捷: 标题文本")
|
||
blockUpdateCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
|
||
blockUpdateCmd.Flags().String("element", "", "块元素 JSON (高级)")
|
||
blockUpdateCmd.Flags().String("content-format", "", "输入格式: 默认为 element,可选 jsonml")
|
||
addJsonMLFlags(blockUpdateCmd)
|
||
|
||
// block delete
|
||
blockDeleteCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
blockDeleteCmd.Flags().String("block-id", "", "目标块 ID (必填)")
|
||
|
||
blockCmd.AddCommand(blockListCmd, blockInsertCmd, blockUpdateCmd, blockDeleteCmd)
|
||
|
||
// copy
|
||
copyCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||
copyCmd.Flags().String("folder", "", "目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
copyCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录)")
|
||
|
||
// move
|
||
moveCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||
moveCmd.Flags().String("folder", "", "目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id")
|
||
moveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录)")
|
||
|
||
// rename
|
||
renameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||
renameCmd.Flags().String("name", "", "新名称 (必填;原样传给服务端,不做扩展名规范化;如需根据节点类型和当前后缀规范化,请使用 drive rename)")
|
||
|
||
// delete
|
||
deleteCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||
|
||
// 别名注册: --node 的隐藏别名 (--url/--id/--node-id/--doc-id/--file-id)
|
||
nodeAliasCmds := []*cobra.Command{
|
||
infoCmd, readCmd, updateCmd, downloadCmd,
|
||
blockListCmd, blockInsertCmd, blockUpdateCmd, blockDeleteCmd,
|
||
copyCmd, moveCmd, renameCmd, deleteCmd,
|
||
}
|
||
for _, c := range nodeAliasCmds {
|
||
c.Flags().String("url", "", "--node 的别名")
|
||
c.Flags().String("id", "", "--node 的别名")
|
||
c.Flags().String("node-id", "", "--node 的别名")
|
||
c.Flags().String("doc-id", "", "--node 的别名")
|
||
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
|
||
_ = c.Flags().MarkHidden("url")
|
||
_ = c.Flags().MarkHidden("id")
|
||
_ = c.Flags().MarkHidden("node-id")
|
||
_ = c.Flags().MarkHidden("doc-id")
|
||
_ = c.Flags().MarkHidden("file-id")
|
||
}
|
||
|
||
// 别名注册: doc create/file create/folder create/rename --title → --name
|
||
createCmd.Flags().String("title", "", "--name 的别名")
|
||
_ = createCmd.Flags().MarkHidden("title")
|
||
fileCreateCmd.Flags().String("title", "", "--name 的别名")
|
||
_ = fileCreateCmd.Flags().MarkHidden("title")
|
||
folderCreateCmd.Flags().String("title", "", "--name 的别名")
|
||
_ = folderCreateCmd.Flags().MarkHidden("title")
|
||
renameCmd.Flags().String("title", "", "--name 的别名")
|
||
_ = renameCmd.Flags().MarkHidden("title")
|
||
|
||
// ── media (文档媒体/附件) ────────────────────────────────
|
||
mediaCmd := &cobra.Command{
|
||
Use: "media",
|
||
Short: "文档媒体 / 附件管理",
|
||
Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
mediaDownloadCmd := &cobra.Command{
|
||
Use: "download",
|
||
Short: "下载文档附件",
|
||
Long: `获取钉钉文档中指定附件的 OSS 临时下载链接。
|
||
|
||
传入 nodeId(文档标识)和 resourceId(附件资源 ID),返回 downloadUrl。
|
||
resourceId 需通过 dws doc block list 获取:查询目标文档的块列表,
|
||
找到 blockType 为 attachment 的元素,取其 resourceId。`,
|
||
Example: ` dws doc media download --node DOC_ID --resource-id RESOURCE_ID
|
||
dws doc media download --node "https://alidocs.dingtalk.com/i/nodes/xxx" --resource-id RESOURCE_ID`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "resource-id"); err != nil {
|
||
return err
|
||
}
|
||
return callMCPToolUnescaped("download_doc_attachment", map[string]any{
|
||
"nodeId": nodeID,
|
||
"resourceId": mustGetFlag(cmd, "resource-id"),
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(mediaDownloadCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "download_doc_attachment",
|
||
CanonicalPath: "doc.download_doc_attachment",
|
||
CLIPath: "doc media download",
|
||
PrimaryCLIPath: "doc media download",
|
||
},
|
||
Description: "获取文档附件的临时下载链接",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "download_doc_attachment"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取文档附件的临时下载链接",
|
||
UseWhen: []string{"获取文档正文中附件的临时下载 URL(resourceId 来自 block list attachment)时"},
|
||
AvoidWhen: []string{"下载钉盘普通文件用 drive download;导出在线文档用 doc export"},
|
||
Examples: []string{"dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
|
||
|
||
mediaUploadCmd := &cobra.Command{
|
||
Use: "upload",
|
||
Short: "上传可复用的文档媒体资源",
|
||
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
|
||
|
||
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
|
||
等后续写入使用;临时 uploadUrl 不会输出。`,
|
||
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
|
||
RunE: runDocMediaUpload,
|
||
}
|
||
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "media_upload",
|
||
CanonicalPath: "doc.media_upload",
|
||
CLIPath: "doc media upload",
|
||
PrimaryCLIPath: "doc media upload",
|
||
},
|
||
Description: "上传可复用的文档媒体资源",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
|
||
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
|
||
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
|
||
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||
{Name: "file", Required: boolPtr(true)},
|
||
},
|
||
},
|
||
})
|
||
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
|
||
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
|
||
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
|
||
|
||
mediaInsertCmd := &cobra.Command{
|
||
Use: "insert",
|
||
Short: "上传附件并插入文档",
|
||
Long: `将本地文件作为附件上传并插入到钉钉文档中(三步自动完成)。
|
||
|
||
流程:
|
||
1. 获取附件上传凭证 (get_doc_attachment_upload_info)
|
||
2. HTTP PUT 上传文件到 OSS
|
||
3. 插入附件块到文档 (insert_document_block)
|
||
|
||
--mime-type 可选,不指定时根据文件扩展名自动推断。`,
|
||
Example: ` # 插入 PDF 附件
|
||
dws doc media insert --node DOC_ID --file ./report.pdf
|
||
|
||
# 指定名称和 MIME 类型
|
||
dws doc media insert --node DOC_ID --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream
|
||
|
||
# 在指定块之前插入
|
||
dws doc media insert --node DOC_ID --file ./image.png --ref-block BLOCK_ID --where before`,
|
||
RunE: runMediaInsert,
|
||
}
|
||
DeclareLeafMetadata(mediaInsertCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "media_insert",
|
||
CanonicalPath: "doc.media_insert",
|
||
CLIPath: "doc media insert",
|
||
PrimaryCLIPath: "doc media insert",
|
||
},
|
||
Description: "上传本地文件并作为附件插入文档",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "上传本地文件并作为附件插入文档",
|
||
UseWhen: []string{"把本地文件/图片作为附件块插入文档正文(自动 prepare+PUT+insert)时"},
|
||
AvoidWhen: []string{
|
||
"上传为独立文件用 drive/doc upload,不要与正文附件混淆",
|
||
"下载正文附件用 media download",
|
||
},
|
||
Examples: []string{"dws doc media insert --node <DOC_ID> --file ./report.pdf --format json"},
|
||
},
|
||
},
|
||
})
|
||
|
||
mediaInsertCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
mediaInsertCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||
mediaInsertCmd.Flags().String("name", "", "附件显示名称 (默认使用文件名)")
|
||
mediaInsertCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||
mediaInsertCmd.Flags().Int("index", 0, "插入位置索引")
|
||
mediaInsertCmd.Flags().String("where", "", "相对位置: before / after (配合 --ref-block)")
|
||
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
|
||
|
||
// media 子命令的 --node 隐藏别名
|
||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
|
||
for _, c := range mediaNodeAliasCmds {
|
||
c.Flags().String("url", "", "--node 的别名")
|
||
c.Flags().String("id", "", "--node 的别名")
|
||
c.Flags().String("node-id", "", "--node 的别名")
|
||
c.Flags().String("doc-id", "", "--node 的别名")
|
||
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
|
||
_ = c.Flags().MarkHidden("url")
|
||
_ = c.Flags().MarkHidden("id")
|
||
_ = c.Flags().MarkHidden("node-id")
|
||
_ = c.Flags().MarkHidden("doc-id")
|
||
_ = c.Flags().MarkHidden("file-id")
|
||
}
|
||
|
||
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
|
||
|
||
// ── comment (文档评论) ──────────────────────────────────
|
||
commentCmd := &cobra.Command{
|
||
Use: "comment",
|
||
Short: "文档评论 / 评论管理",
|
||
Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
commentListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "查询文档评论列表",
|
||
Long: `查询指定文档的评论列表,支持分页、按评论类型和解决状态过滤。
|
||
|
||
评论类型 (--type):
|
||
global 全文评论
|
||
inline 划词评论
|
||
不传返回所有评论
|
||
|
||
解决状态 (--resolve-status):
|
||
resolved 已解决
|
||
unresolved 未解决
|
||
不传返回所有评论`,
|
||
Example: ` dws doc comment list --node DOC_ID
|
||
dws doc comment list --node "https://alidocs.dingtalk.com/i/nodes/xxx" --limit 20
|
||
dws doc comment list --node DOC_ID --type inline --resolve-status unresolved
|
||
dws doc comment list --node DOC_ID --cursor TOKEN_FROM_PREVIOUS_PAGE`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||
toolArgs["pageSize"] = v
|
||
} else if v, _ := cmd.Flags().GetInt("page-size"); cmd.Flags().Changed("page-size") {
|
||
toolArgs["pageSize"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||
toolArgs["nextToken"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("type"); v != "" {
|
||
toolArgs["commentType"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("resolve-status"); v != "" {
|
||
toolArgs["resolveStatus"] = v
|
||
}
|
||
return callMCPToolOnServer("doc-comment", "list_comments", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(commentListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "list_comments",
|
||
CanonicalPath: "doc.list_comments",
|
||
CLIPath: "doc comment list",
|
||
PrimaryCLIPath: "doc comment list",
|
||
},
|
||
Description: "查询文档评论列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc-comment", RPCName: "list_comments"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询文档评论列表",
|
||
UseWhen: []string{"查看文档评论列表,可按全文/划词、已解决/未解决过滤时"},
|
||
AvoidWhen: []string{"创建全文评论用 comment create;划词用 create-inline;回复用 reply;删除用 delete"},
|
||
Examples: []string{
|
||
"dws doc comment list --node <DOC_ID> --format json",
|
||
"dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "cursor", Property: "nextToken"},
|
||
{Name: "limit", Property: "pageSize"},
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "type", Property: "commentType"},
|
||
},
|
||
},
|
||
})
|
||
|
||
commentListCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
commentListCmd.Flags().Int("limit", 50, "每页返回的评论数量,默认 50,最大 50")
|
||
commentListCmd.Flags().Int("page-size", 0, "")
|
||
_ = commentListCmd.Flags().MarkHidden("page-size")
|
||
commentListCmd.Flags().String("cursor", "", "分页游标,从上一次请求的返回结果中获取 (首次请求不传)")
|
||
commentListCmd.Flags().String("next-token", "", "")
|
||
_ = commentListCmd.Flags().MarkHidden("next-token")
|
||
commentListCmd.Flags().String("type", "", "按评论类型过滤: global (全文评论) / inline (划词评论)")
|
||
commentListCmd.Flags().String("resolve-status", "", "按解决状态过滤: resolved (已解决) / unresolved (未解决)")
|
||
|
||
commentCreateCmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: "创建文档评论",
|
||
Long: `在指定文档上创建一条评论。
|
||
|
||
可通过 --mention 指定被 @ 的用户 uid 列表(逗号分隔),通过
|
||
--mentioned-open-conversation-id 指定被 @ 的群 openConversationId(可重复或逗号分隔)。
|
||
评论内容中会插入 @mention 节点并发送通知。
|
||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||
dws contact user search --keyword "姓名"`,
|
||
Example: ` dws doc comment create --node DOC_ID --content "这里需要修改"
|
||
dws doc comment create --node DOC_ID --content "请review" --mention uid1,uid2
|
||
dws doc comment create --node DOC_ID --content "请群内同学关注" --mentioned-open-conversation-id openCid1 --mentioned-open-conversation-id openCid2`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "content"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"content": mustGetFlag(cmd, "content"),
|
||
}
|
||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||
}
|
||
if err := appendCommentGroupMentions(cmd, toolArgs); err != nil {
|
||
return err
|
||
}
|
||
return callMCPToolOnServer("doc-comment", "create_comment", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(commentCreateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "create_comment",
|
||
CanonicalPath: "doc.create_comment",
|
||
CLIPath: "doc comment create",
|
||
PrimaryCLIPath: "doc comment create",
|
||
},
|
||
Description: "创建文档评论",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc-comment", RPCName: "create_comment"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "创建文档评论",
|
||
UseWhen: []string{"在文档上创建不绑定具体划词位置的全文评论,可 @用户或通过 --mentioned-open-conversation-id @群"},
|
||
AvoidWhen: []string{
|
||
"针对某段选中文本的划词评论改用 create-inline(需 blockId+start/end)",
|
||
"回复已有评论用 reply;删评论用 delete",
|
||
},
|
||
Examples: []string{
|
||
"dws doc comment create --node <DOC_ID> --content \"这里需要修改\" --format json",
|
||
"dws doc comment create --node <DOC_ID> --content \"请review\" --mention uid1,uid2 --mentioned-open-conversation-id <openConversationId> --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "mentioned-open-conversation-id", Required: boolPtr(false), InterfaceType: "array"},
|
||
{Name: "mention", Property: "mentionedUserIds"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
commentCreateCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
commentCreateCmd.Flags().String("content", "", "评论的文字内容,纯文本 (必填)")
|
||
commentCreateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||
addCommentGroupMentionFlag(commentCreateCmd)
|
||
|
||
commentReplyCmd := &cobra.Command{
|
||
Use: "reply",
|
||
Short: "回复评论",
|
||
Long: `回复指定文档中的一条评论。
|
||
|
||
--comment-key 为被回复评论的唯一标识(即 list 返回的 commentKey),格式:{13位毫秒时间戳}{32位UUID},共45位。
|
||
commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中获取。
|
||
|
||
可通过 --mention 指定被 @ 的用户 uid 列表(逗号分隔),通过
|
||
--mentioned-open-conversation-id 指定被 @ 的群 openConversationId(可重复或逗号分隔)。
|
||
评论内容中会插入 @mention 节点并发送通知。
|
||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||
dws contact user search --keyword "姓名"
|
||
|
||
设置 --emoji 时,本次回复将作为表情贴图回复,--content 填写表情名称。`,
|
||
Example: ` dws doc comment reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"
|
||
dws doc comment reply --node DOC_ID --comment-key COMMENT_KEY --content "比心" --emoji
|
||
dws doc comment reply --node DOC_ID --comment-key COMMENT_KEY --content "请确认" --mention uid1,uid2
|
||
dws doc comment reply --node DOC_ID --comment-key COMMENT_KEY --content "请群内确认" --mentioned-open-conversation-id openCid1`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "content", "comment-key"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"content": mustGetFlag(cmd, "content"),
|
||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||
}
|
||
if v, _ := cmd.Flags().GetBool("emoji"); v {
|
||
groupMentions, err := commentGroupMentionIDs(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(groupMentions) > 0 {
|
||
return fmt.Errorf("--emoji cannot be used with --mentioned-open-conversation-id: emoji replies do not support group mentions")
|
||
}
|
||
toolArgs["emoji"] = true
|
||
}
|
||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||
}
|
||
if err := appendCommentGroupMentions(cmd, toolArgs); err != nil {
|
||
return err
|
||
}
|
||
return callMCPToolOnServer("doc-comment", "reply_comment", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(commentReplyCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "reply_comment",
|
||
CanonicalPath: "doc.reply_comment",
|
||
CLIPath: "doc comment reply",
|
||
PrimaryCLIPath: "doc comment reply",
|
||
},
|
||
Description: "回复文档评论",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc-comment", RPCName: "reply_comment"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "回复文档评论",
|
||
UseWhen: []string{"回复已有评论(文字、可 @用户/@群,或 --emoji 表情);commentKey 来自 list/create"},
|
||
AvoidWhen: []string{"新建评论用 create/create-inline;删评论用 delete"},
|
||
Examples: []string{
|
||
"dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"同意\" --mentioned-open-conversation-id <openConversationId> --format json",
|
||
"dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"比心\" --emoji --format json",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "mentioned-open-conversation-id", Required: boolPtr(false), InterfaceType: "array"},
|
||
{Name: "comment-key", Property: "replyCommentKey"},
|
||
{Name: "mention", Property: "mentionedUserIds"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
commentReplyCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
commentReplyCmd.Flags().String("content", "", "回复的文字内容,表情回复时填写表情名称 (必填)")
|
||
commentReplyCmd.Flags().String("comment-key", "", "被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)")
|
||
commentReplyCmd.Flags().Bool("emoji", false, "设为 true 时作为表情贴图回复 (默认 false)")
|
||
commentReplyCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||
addCommentGroupMentionFlag(commentReplyCmd)
|
||
|
||
commentUpdateCmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: "更新文档评论",
|
||
Long: `更新指定文档中的一条评论。
|
||
|
||
--comment-key 为待更新评论的唯一标识,可从 comment list、create 或 create-inline 的返回结果中获取。
|
||
可通过 --mention 指定更新后评论中被 @ 的用户 uid 列表,通过
|
||
--mentioned-open-conversation-id 指定被 @ 的群 openConversationId(可重复或逗号分隔)。`,
|
||
Example: ` dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "已按最新数据修正"
|
||
dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "请确认" --mention uid1,uid2
|
||
dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "请群内同学关注" --mentioned-open-conversation-id openCid1`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "comment-key", "content"); err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"commentKey": mustGetFlag(cmd, "comment-key"),
|
||
"content": mustGetFlag(cmd, "content"),
|
||
}
|
||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||
}
|
||
if err := appendCommentGroupMentions(cmd, toolArgs); err != nil {
|
||
return err
|
||
}
|
||
return callMCPToolOnServer("doc-comment", "update_comment", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(commentUpdateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "update_comment",
|
||
CanonicalPath: "doc.update_comment",
|
||
CLIPath: "doc comment update",
|
||
PrimaryCLIPath: "doc comment update",
|
||
},
|
||
Description: "更新指定文档评论的文字内容和可选 @用户/@群。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "更新指定文档评论的文字内容和可选 @用户/@群。",
|
||
UseWhen: []string{"修改已有评论正文;可选更新 --mention 或 --mentioned-open-conversation-id"},
|
||
AvoidWhen: []string{"删除评论用 delete;回复用 reply"},
|
||
Examples: []string{
|
||
"dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"已按最新数据修正\" --format json",
|
||
"dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content \"请群内确认\" --mentioned-open-conversation-id <openConversationId>",
|
||
},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "mention", Property: "mentionedUserIds", InterfaceType: "array"},
|
||
{Name: "mentioned-open-conversation-id", Property: "mentionedOpenConversationIds", Required: boolPtr(false), InterfaceType: "array"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
commentUpdateCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
commentUpdateCmd.Flags().String("comment-key", "", "待更新评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||
commentUpdateCmd.Flags().String("content", "", "更新后的评论文字内容,纯文本 (必填)")
|
||
commentUpdateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||
addCommentGroupMentionFlag(commentUpdateCmd)
|
||
|
||
commentDeleteCmd := &cobra.Command{
|
||
Use: "delete",
|
||
Short: "删除文档评论",
|
||
Long: `删除指定文档中的一条评论。
|
||
|
||
这是不可恢复的危险操作。执行前需要交互确认,或在用户已明确同意后传入全局 --yes 跳过确认。`,
|
||
Example: ` dws doc comment delete --node DOC_ID --comment-key COMMENT_KEY --yes`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
|
||
return err
|
||
}
|
||
commentKey := mustGetFlag(cmd, "comment-key")
|
||
return callMCPToolOnServer("doc-comment", "delete_comment", map[string]any{
|
||
"nodeId": nodeID,
|
||
"commentKey": commentKey,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(commentDeleteCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "user_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "delete_comment",
|
||
CanonicalPath: "doc.delete_comment",
|
||
CLIPath: "doc comment delete",
|
||
PrimaryCLIPath: "doc comment delete",
|
||
},
|
||
Description: "永久删除指定文档中的一条评论",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "永久删除指定文档中的一条评论",
|
||
UseWhen: []string{"用户明确要求永久删除指定文档中的某条评论(已有 commentKey)时"},
|
||
AvoidWhen: []string{"只需改文案用 update;回复用 reply;目标评论不明或未确认时不要删"},
|
||
Examples: []string{"dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
commentDeleteCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
commentDeleteCmd.Flags().String("comment-key", "", "待删除评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||
|
||
commentCreateInlineCmd := &cobra.Command{
|
||
Use: "create-inline",
|
||
Short: "创建划词评论",
|
||
Long: `在指定文档的选中文本区域上创建一条划词评论。
|
||
|
||
需要指定评论标记所在的块 ID (--block-id)、起始偏移量 (--start) 和结束偏移量 (--end)。
|
||
块 ID 可通过 dws doc block list --node <nodeId> 获取。
|
||
|
||
可通过 --selected-text 传入选中文本内容,评论列表中会展示「引用原文:xxx」。
|
||
可通过 --mention 指定被 @ 的用户 uid 列表(逗号分隔),
|
||
评论内容中会插入 @mention 节点并发送通知。
|
||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||
dws contact user search --keyword "姓名"`,
|
||
Example: ` dws doc comment create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"
|
||
dws doc comment create-inline --node DOC_ID --block-id BLOCK_ID --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
|
||
dws doc comment create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "请review" --mention uid1,uid2`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "content", "block-id"); err != nil {
|
||
return err
|
||
}
|
||
if !cmd.Flags().Changed("start") || !cmd.Flags().Changed("end") {
|
||
return fmt.Errorf("missing required flag(s): --start, --end")
|
||
}
|
||
start, _ := cmd.Flags().GetInt("start")
|
||
end, _ := cmd.Flags().GetInt("end")
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"content": mustGetFlag(cmd, "content"),
|
||
"blockId": mustGetFlag(cmd, "block-id"),
|
||
"start": start,
|
||
"end": end,
|
||
}
|
||
if v, _ := cmd.Flags().GetString("selected-text"); v != "" {
|
||
toolArgs["selectedText"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||
}
|
||
return callMCPToolOnServer("doc-comment", "create_inline_comment", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(commentCreateInlineCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "create_inline_comment",
|
||
CanonicalPath: "doc.create_inline_comment",
|
||
CLIPath: "doc comment create-inline",
|
||
PrimaryCLIPath: "doc comment create-inline",
|
||
},
|
||
Description: "在文档选中文本范围创建划词评论",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc-comment", RPCName: "create_inline_comment"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "在文档选中文本范围创建划词评论",
|
||
UseWhen: []string{"针对块内某段文本创建划词评论(必填 blockId、start、end)时"},
|
||
AvoidWhen: []string{
|
||
"不绑定位置的全文评论用 create",
|
||
"先 block list 取 blockId 与纯文本偏移再调用",
|
||
},
|
||
Examples: []string{"dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content \"这里需要修改\" --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "mention", Property: "mentionedUserIds"},
|
||
{Name: "node", Property: "nodeId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
commentCreateInlineCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||
commentCreateInlineCmd.Flags().String("content", "", "评论的文字内容,纯文本 (必填)")
|
||
commentCreateInlineCmd.Flags().String("block-id", "", "评论标记所在的块 ID,可通过 dws doc block list 获取 (必填)")
|
||
commentCreateInlineCmd.Flags().Int("start", 0, "评论标记在块内文本中的起始字符偏移量,从 0 开始 (必填)")
|
||
commentCreateInlineCmd.Flags().Int("end", 0, "评论标记在块内文本中的结束字符偏移量,必须大于 start (必填)")
|
||
commentCreateInlineCmd.Flags().String("selected-text", "", "选中文本的内容,填写后评论列表中会展示「引用原文:xxx」")
|
||
commentCreateInlineCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||
|
||
// comment 子命令的 --node 隐藏别名
|
||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd}
|
||
for _, c := range commentNodeAliasCmds {
|
||
c.Flags().String("url", "", "--node 的别名")
|
||
c.Flags().String("id", "", "--node 的别名")
|
||
c.Flags().String("node-id", "", "--node 的别名")
|
||
c.Flags().String("doc-id", "", "--node 的别名")
|
||
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
|
||
_ = c.Flags().MarkHidden("url")
|
||
_ = c.Flags().MarkHidden("id")
|
||
_ = c.Flags().MarkHidden("node-id")
|
||
_ = c.Flags().MarkHidden("doc-id")
|
||
_ = c.Flags().MarkHidden("file-id")
|
||
}
|
||
|
||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
|
||
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
|
||
|
||
// ── permission (文档协作权限) ────────────────────────────
|
||
permissionCmd := &cobra.Command{
|
||
Use: "permission",
|
||
Aliases: []string{"perm"},
|
||
Short: "文档协作权限管理",
|
||
Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
permissionAddCmd := &cobra.Command{
|
||
Use: "add",
|
||
Short: "添加文档协作者",
|
||
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
|
||
|
||
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||
|
||
支持的角色 (--role)(必须大写):
|
||
MANAGER 管理员,可读写、管理成员
|
||
EDITOR 编辑者,可查看、编辑、上传内容
|
||
DOWNLOADER 查看下载者,可查看并下载内容
|
||
READER 仅可查看者,仅可查看,不可下载
|
||
|
||
注意:
|
||
- OWNER 角色不可通过此接口添加。
|
||
- 单次请求最多 30 个成员,超出请分批调用。
|
||
|
||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||
dws contact user search --keyword "姓名"`,
|
||
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
|
||
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
|
||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||
return err
|
||
}
|
||
userIds, err := collectUserIDs(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||
"userIds": userIds,
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool("add_permission", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(permissionAddCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "add_permission",
|
||
CanonicalPath: "doc.add_permission",
|
||
CLIPath: "doc permission add",
|
||
PrimaryCLIPath: "doc permission add",
|
||
},
|
||
Description: "兼容入口:为文档空间节点添加协作成员权限;文件管理权限命令已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "add_permission"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:为文档空间节点添加协作成员权限;文件管理权限命令已迁移到 drive。",
|
||
UseWhen: []string{"给单篇文档做节点级授权(与 drive permission add 同能力的 doc 入口)时"},
|
||
AvoidWhen: []string{
|
||
"知识库容器授权用 wiki member add",
|
||
"查/改/移除用 permission list/update/remove",
|
||
},
|
||
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "role", Property: "roleId"},
|
||
{Name: "users", Property: "userIds"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||
permissionAddCmd.Flags().String("user", "", "")
|
||
_ = permissionAddCmd.Flags().MarkHidden("user")
|
||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||
|
||
permissionUpdateCmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: "更新文档协作者权限",
|
||
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
|
||
|
||
支持的角色 (--role)(必须大写):
|
||
MANAGER 管理员
|
||
EDITOR 编辑者
|
||
DOWNLOADER 查看下载者
|
||
READER 仅可查看者
|
||
|
||
注意:
|
||
- OWNER 角色不可通过此接口变更。
|
||
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
|
||
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
|
||
|
||
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
|
||
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
|
||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||
return err
|
||
}
|
||
userIds, err := collectUserIDs(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||
"userIds": userIds,
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool("update_permission", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(permissionUpdateCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "update_permission",
|
||
CanonicalPath: "doc.update_permission",
|
||
CLIPath: "doc permission update",
|
||
PrimaryCLIPath: "doc permission update",
|
||
},
|
||
Description: "兼容入口:更新文档空间节点的协作成员角色;文件权限命令已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "update_permission"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:更新文档空间节点的协作成员角色;文件权限命令已迁移到 drive。",
|
||
UseWhen: []string{"变更文档节点上已有用户角色时"},
|
||
AvoidWhen: []string{"新授权用 add;移除用 remove"},
|
||
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "role", Property: "roleId"},
|
||
{Name: "users", Property: "userIds"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||
permissionUpdateCmd.Flags().String("user", "", "")
|
||
_ = permissionUpdateCmd.Flags().MarkHidden("user")
|
||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||
|
||
permissionListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Aliases: []string{"ls"},
|
||
Short: "查询文档协作者列表",
|
||
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
|
||
|
||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
|
||
Example: ` dws doc permission list --node DOC_ID
|
||
dws doc permission list --node DOC_ID --limit 100
|
||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
}
|
||
limit := 0
|
||
if cmd.Flags().Changed("limit") {
|
||
limit, _ = cmd.Flags().GetInt("limit")
|
||
} else if cmd.Flags().Changed("max-results") {
|
||
limit, _ = cmd.Flags().GetInt("max-results")
|
||
}
|
||
if limit > 0 {
|
||
toolArgs["maxResults"] = limit
|
||
}
|
||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool("list_permission", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(permissionListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "list_permission",
|
||
CanonicalPath: "doc.list_permission",
|
||
CLIPath: "doc permission list",
|
||
PrimaryCLIPath: "doc permission list",
|
||
},
|
||
Description: "兼容入口:查询文档空间节点的协作者权限;文件权限命令已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "list_permission"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:查询文档空间节点的协作者权限;文件权限命令已迁移到 drive。",
|
||
UseWhen: []string{"列出文档节点成员权限时"},
|
||
AvoidWhen: []string{"增删改权限用 add/update/remove;知识库成员用 wiki member list"},
|
||
Examples: []string{"dws doc permission list --node <DOC_ID> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "filter-role", Property: "filterRoleIds"},
|
||
{Name: "limit", Property: "maxResults"},
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
|
||
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||
permissionListCmd.Flags().Int("max-results", 0, "")
|
||
_ = permissionListCmd.Flags().MarkHidden("max-results")
|
||
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||
|
||
permissionRemoveCmd := &cobra.Command{
|
||
Use: "remove",
|
||
Aliases: []string{"rm"},
|
||
Short: "移除文档协作者权限",
|
||
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
|
||
|
||
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
|
||
|
||
注意:
|
||
- OWNER 角色不可通过此接口移除。
|
||
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
|
||
- 单次请求最多 30 个成员,超出请分批调用。
|
||
|
||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||
dws contact user search --keyword "姓名"`,
|
||
Example: ` dws doc permission remove --node DOC_ID --users uid1
|
||
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
|
||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
userIds, err := collectUserIDs(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"userIds": userIds,
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPTool("remove_permission", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "remove_permission",
|
||
CanonicalPath: "doc.remove_permission",
|
||
CLIPath: "doc permission remove",
|
||
PrimaryCLIPath: "doc permission remove",
|
||
},
|
||
Description: "兼容入口:移除文档空间节点的协作成员权限;文件权限命令已迁移到 drive。",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "remove_permission"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "兼容入口:移除文档空间节点的协作成员权限;文件权限命令已迁移到 drive。",
|
||
UseWhen: []string{"移除文档节点上指定用户权限时"},
|
||
AvoidWhen: []string{"改角色用 update;知识库撤成员用 wiki member remove"},
|
||
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "node", Property: "nodeId"},
|
||
{Name: "users", Property: "userIds"},
|
||
{Name: "workspace", Property: "workspaceId"},
|
||
},
|
||
},
|
||
})
|
||
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||
permissionRemoveCmd.Flags().String("user", "", "")
|
||
_ = permissionRemoveCmd.Flags().MarkHidden("user")
|
||
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||
|
||
// permission 子命令的 --node 隐藏别名
|
||
permissionNodeAliasCmds := []*cobra.Command{permissionAddCmd, permissionUpdateCmd, permissionListCmd, permissionRemoveCmd}
|
||
for _, c := range permissionNodeAliasCmds {
|
||
c.Flags().String("url", "", "--node 的别名")
|
||
c.Flags().String("id", "", "--node 的别名")
|
||
c.Flags().String("node-id", "", "--node 的别名")
|
||
c.Flags().String("doc-id", "", "--node 的别名")
|
||
c.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
|
||
_ = c.Flags().MarkHidden("url")
|
||
_ = c.Flags().MarkHidden("id")
|
||
_ = c.Flags().MarkHidden("node-id")
|
||
_ = c.Flags().MarkHidden("doc-id")
|
||
_ = c.Flags().MarkHidden("file-id")
|
||
}
|
||
|
||
permissionCmd.AddCommand(permissionAddCmd, permissionUpdateCmd, permissionListCmd, permissionRemoveCmd)
|
||
|
||
// ── export: 文档导出(一体化:提交→轮询→下载)──────────────
|
||
exportCmd := &cobra.Command{
|
||
Use: "export",
|
||
Short: "导出在线文档 (支持 docx / markdown / pdf)",
|
||
Long: `将钉钉在线文档 (alidocs) 导出为本地文件。
|
||
|
||
支持的导出格式 (--export-format):
|
||
docx Word 文档 (默认)
|
||
markdown Markdown 文件 (.md)
|
||
pdf PDF 文档 (.pdf)
|
||
|
||
CLI 内部自动完成全部流程:
|
||
1. 提交导出任务
|
||
2. 渐进式退避轮询等待完成(最多约 5 分钟)
|
||
3. 导出成功后自动下载文件到 --output 指定路径
|
||
|
||
如果轮询超时仍未完成,会输出 jobId 供后续手动查询:
|
||
dws doc export get --job-id <jobId>`,
|
||
Example: ` # 导出为 docx (默认)
|
||
dws doc export --node "https://alidocs.dingtalk.com/i/nodes/xxx" --output ./exported.docx
|
||
|
||
# 导出为 markdown
|
||
dws doc export --node <DOC_ID> --export-format markdown --output ./exported.md
|
||
|
||
# --output 传入目录时,根据 --export-format 自动追加扩展名
|
||
dws doc export --node <DOC_ID> --export-format markdown --output ~/downloads/`,
|
||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||
node, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
outputPath, _ := cmd.Flags().GetString("output")
|
||
if outputPath == "" {
|
||
return fmt.Errorf("flag --output is required")
|
||
}
|
||
|
||
// 解析导出格式:优先 --export-format,兼容旧的 --format 别名
|
||
// 注意:--format 与全局输出格式 flag 同名,需排除 json/table/raw/pretty 等全局值
|
||
format, _ := cmd.Flags().GetString("export-format")
|
||
if format == "" {
|
||
if legacy, _ := cmd.Flags().GetString("format"); legacy != "" {
|
||
// 排除全局输出格式值(当 --format json 来自 conftest 或用户误传时不应视为导出格式)
|
||
globalFormats := map[string]bool{"json": true, "table": true, "raw": true, "pretty": true}
|
||
if !globalFormats[strings.ToLower(legacy)] {
|
||
format = legacy
|
||
}
|
||
}
|
||
}
|
||
if format == "" {
|
||
format = "docx"
|
||
}
|
||
format = strings.ToLower(format)
|
||
// 格式 → 文件扩展名映射(含别名)
|
||
formatExtMap := map[string]string{
|
||
"docx": ".docx",
|
||
"markdown": ".md",
|
||
"md": ".md",
|
||
"pdf": ".pdf",
|
||
}
|
||
fileExt, ok := formatExtMap[format]
|
||
if !ok {
|
||
return fmt.Errorf("unsupported --format %q, expected one of: docx, markdown (or md), pdf", format)
|
||
}
|
||
// 规范化为 MCP 接受的格式名("md" → "markdown")
|
||
if format == "md" {
|
||
format = "markdown"
|
||
}
|
||
|
||
submitArgs := map[string]any{
|
||
"nodeId": node,
|
||
"exportFormat": format,
|
||
}
|
||
|
||
if deps.Caller.DryRun() {
|
||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"dry_run": true,
|
||
"executed": false,
|
||
"preview_kind": "plan",
|
||
"operation": "doc_export",
|
||
"nodeId": node,
|
||
"exportFormat": format,
|
||
"savedPath": outputPath,
|
||
})
|
||
}
|
||
deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)")
|
||
deps.Out.PrintKeyValue("文档", node)
|
||
deps.Out.PrintKeyValue("输出", outputPath)
|
||
deps.Out.PrintKeyValue("格式", format)
|
||
return nil
|
||
}
|
||
|
||
ctx := context.Background()
|
||
|
||
// ── Step 1: 提交导出任务 ──
|
||
printJSONSafeInfo("[1/3] 提交导出任务...")
|
||
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs)
|
||
if err != nil {
|
||
return fmt.Errorf("提交导出任务失败: %w", err)
|
||
}
|
||
|
||
var submitResult map[string]any
|
||
if err := json.Unmarshal([]byte(submitText), &submitResult); err != nil {
|
||
return fmt.Errorf("解析提交结果失败: %w", err)
|
||
}
|
||
jobID, _ := submitResult["jobId"].(string)
|
||
if jobID == "" {
|
||
deps.Out.PrintRaw(submitText)
|
||
return fmt.Errorf("提交导出任务成功但未返回 jobId")
|
||
}
|
||
printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
|
||
|
||
// ── Step 2: 渐进式退避轮询 ──
|
||
printJSONSafeInfo("[2/3] 等待导出完成...")
|
||
downloadURL, err := pollDocExportJob(ctx, jobID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// ── Step 3: 下载文件 ──
|
||
fi, statErr := os.Stat(outputPath)
|
||
if statErr == nil && fi.IsDir() {
|
||
filename := inferFilename(downloadURL)
|
||
if ext := filepath.Ext(filename); ext == "" {
|
||
filename += fileExt
|
||
} else if !strings.EqualFold(ext, fileExt) {
|
||
// 推断到的扩展名与请求格式不一致时,统一使用请求格式的扩展名
|
||
filename = strings.TrimSuffix(filename, ext) + fileExt
|
||
}
|
||
outputPath = filepath.Join(outputPath, filename)
|
||
}
|
||
|
||
printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
|
||
if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil {
|
||
return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err)
|
||
}
|
||
|
||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||
info, err := os.Stat(outputPath)
|
||
if err != nil {
|
||
return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err)
|
||
}
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"success": true,
|
||
"nodeId": node,
|
||
"exportFormat": format,
|
||
"jobId": jobID,
|
||
"taskId": jobID,
|
||
"status": "SUCCESS",
|
||
"savedPath": outputPath,
|
||
"sizeBytes": info.Size(),
|
||
})
|
||
}
|
||
|
||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||
return nil
|
||
},
|
||
}
|
||
exportCmd.Flags().String("node", "", "要导出的文档标识,支持文档 URL 或 dentryUuid (必填)")
|
||
exportCmd.Flags().String("export-format", "docx", "导出格式:docx (默认) / markdown (或 md) / pdf")
|
||
exportCmd.Flags().String("format", "", "--export-format 的别名 (向后兼容,与全局 --format 冲突时以 --export-format 为准)")
|
||
_ = exportCmd.Flags().MarkHidden("format")
|
||
exportCmd.Flags().String("output", "", "本地保存路径,文件路径或目录 (必填)")
|
||
|
||
// export get: 手动查询已有任务状态(兜底用)
|
||
exportGetCmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: "查询导出任务结果(手动兜底)",
|
||
Long: `根据 jobId 查询文档导出任务的执行结果。
|
||
通常不需要手动调用,dws doc export 会自动完成轮询。
|
||
仅在导出命令超时或中断后,用于手动查询任务状态。
|
||
|
||
任务状态:
|
||
PROCESSING 处理中
|
||
SUCCESS 导出成功,返回 downloadUrl
|
||
FAILED 导出失败`,
|
||
Example: ` dws doc export get --job-id <JOB_ID>
|
||
dws doc export get --task-id <TASK_ID>`,
|
||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
|
||
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if deps.Caller.DryRun() {
|
||
deps.Out.PrintKeyValue("操作", "查询导出任务结果")
|
||
deps.Out.PrintKeyValue("任务ID", jobID)
|
||
return nil
|
||
}
|
||
|
||
ctx := context.Background()
|
||
text, err := callMCPToolReturnText(ctx, "query_export_job", map[string]any{"jobId": jobID})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var result map[string]any
|
||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||
deps.Out.PrintRaw(text)
|
||
return nil
|
||
}
|
||
|
||
status, _ := result["status"].(string)
|
||
message, _ := result["message"].(string)
|
||
normalizedStatus := strings.ToUpper(status)
|
||
|
||
switch normalizedStatus {
|
||
case "SUCCESS":
|
||
deps.Out.PrintJSON(result)
|
||
return nil
|
||
case "PROCESSING":
|
||
deps.Out.PrintJSON(result)
|
||
return nil
|
||
default:
|
||
deps.Out.PrintJSON(result)
|
||
if message != "" {
|
||
return fmt.Errorf("导出任务失败 (status=%s): %s", status, message)
|
||
}
|
||
return fmt.Errorf("导出任务失败 (status=%s)", status)
|
||
}
|
||
},
|
||
}
|
||
DeclareLeafMetadata(exportGetCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "query_export_job",
|
||
CanonicalPath: "doc.query_export_job",
|
||
CLIPath: "doc export get",
|
||
PrimaryCLIPath: "doc export get",
|
||
},
|
||
Description: "查询文档导出任务结果",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "mcp",
|
||
Availability: "available",
|
||
Ref: &contract.InterfaceRefSpec{ProductID: "doc", RPCName: "query_export_job"},
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查询文档导出任务结果",
|
||
UseWhen: []string{"doc export 超时/中断后,用 jobId 查询导出任务状态与下载链接时"},
|
||
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
|
||
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
|
||
},
|
||
Parameters: []contract.ParamDecl{
|
||
{Name: "job-id", Property: "jobId"},
|
||
},
|
||
},
|
||
})
|
||
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
|
||
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
|
||
_ = exportGetCmd.Flags().MarkHidden("task-id")
|
||
|
||
// --node 的隐藏别名(与 doc 下其他命令保持一致)
|
||
exportCmd.Flags().String("url", "", "--node 的别名")
|
||
exportCmd.Flags().String("id", "", "--node 的别名")
|
||
exportCmd.Flags().String("node-id", "", "--node 的别名")
|
||
exportCmd.Flags().String("doc-id", "", "--node 的别名")
|
||
exportCmd.Flags().String("file-id", "", "--node 的别名 (跨产品兼容 drive)")
|
||
_ = exportCmd.Flags().MarkHidden("url")
|
||
_ = exportCmd.Flags().MarkHidden("id")
|
||
_ = exportCmd.Flags().MarkHidden("node-id")
|
||
_ = exportCmd.Flags().MarkHidden("doc-id")
|
||
_ = exportCmd.Flags().MarkHidden("file-id")
|
||
|
||
exportCmd.AddCommand(exportGetCmd)
|
||
|
||
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
|
||
importCmd := &cobra.Command{
|
||
Use: "import",
|
||
Short: "导入本地文件为在线文档 (支持 docx / xlsx / md 等)",
|
||
Long: `将本地文件导入为钉钉在线文档。
|
||
|
||
支持的文件格式 (按扩展名):
|
||
docx, doc → 文字文档
|
||
xlsx, xls → 电子表格
|
||
md, txt → 文字文档
|
||
xmind, mark → 脑图
|
||
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
|
||
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
|
||
为 md;上传到钉盘请用 dws drive upload
|
||
|
||
文件大小限制: 20MB
|
||
|
||
CLI 内部自动完成全部流程:
|
||
1. 创建导入会话(获取 OSS 上传凭证)
|
||
2. 上传文件到 OSS
|
||
3. 确认导入(触发格式转换)
|
||
4. 渐进式退避轮询等待完成(最多约 5 分钟)
|
||
|
||
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
|
||
dws doc import get --task-id <taskId>`,
|
||
Example: ` # 导入 Word 文档
|
||
dws doc import --file ./report.docx
|
||
|
||
# 导入到指定文件夹
|
||
dws doc import --file ./notes.md --folder <FOLDER_ID>
|
||
|
||
# 导入到知识库根目录
|
||
dws doc import --file ./data.xlsx --workspace <WORKSPACE_ID>
|
||
|
||
# 自定义导入后的文档名称
|
||
dws doc import --file ./draft.md --name "项目周报"`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runImportCommand(cmd, args, docImportFlowConfig())
|
||
},
|
||
}
|
||
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
|
||
importCmd.Flags().String("folder-id", "", "")
|
||
_ = importCmd.Flags().MarkHidden("folder-id")
|
||
importCmd.Flags().String("workspace-id", "", "")
|
||
_ = importCmd.Flags().MarkHidden("workspace-id")
|
||
|
||
importGetCmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: "查询导入任务结果(手动兜底)",
|
||
Long: `根据 taskId 查询文档导入任务的执行结果。
|
||
通常不需要手动调用,dws doc import 会自动完成轮询。
|
||
仅在导入命令超时或中断后,用于手动查询任务状态。
|
||
|
||
任务状态:
|
||
processing 转换中
|
||
completed 导入成功,返回 documentUrl
|
||
failed 导入失败`,
|
||
Example: ` dws doc import get --task-id <TASK_ID>`,
|
||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||
return runImportGetCommand(cmd, docImportFlowConfig())
|
||
},
|
||
}
|
||
DeclareLeafMetadata(importGetCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "import_get",
|
||
CanonicalPath: "doc.import_get",
|
||
CLIPath: "doc import get",
|
||
PrimaryCLIPath: "doc import get",
|
||
},
|
||
Description: "根据 taskId 查询文档导入任务的执行结果",
|
||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "根据 taskId 查询文档导入任务的执行结果",
|
||
UseWhen: []string{"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"},
|
||
AvoidWhen: []string{"发起导入用 doc import(若入口可用);不要用本命令代替导入"},
|
||
Examples: []string{"dws doc import get --task-id <TASK_ID> --format json"},
|
||
},
|
||
},
|
||
})
|
||
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
|
||
importCmd.AddCommand(importGetCmd)
|
||
|
||
// ── doc version 子命令组 ──
|
||
versionCmd := &cobra.Command{
|
||
Use: "version",
|
||
Short: "文档历史版本管理",
|
||
Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
|
||
RunE: groupRunE,
|
||
}
|
||
|
||
versionSaveCmd := &cobra.Command{
|
||
Use: "save",
|
||
Short: "手动保存文档版本快照",
|
||
Example: ` dws doc version save --node DOC_ID`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return callMCPToolOnServer("doc", "save_doc_version", map[string]any{
|
||
"nodeId": nodeID,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(versionSaveCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "version_save",
|
||
CanonicalPath: "doc.version_save",
|
||
CLIPath: "doc version save",
|
||
PrimaryCLIPath: "doc version save",
|
||
},
|
||
Description: "手动保存文档版本快照",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "手动保存文档版本快照",
|
||
UseWhen: []string{"手动保存当前文档版本快照时"},
|
||
AvoidWhen: []string{"回滚用 revert;只看历史用 list"},
|
||
Examples: []string{"dws doc version save --node <DOC_ID> --format json"},
|
||
},
|
||
},
|
||
})
|
||
versionSaveCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
|
||
versionListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Aliases: []string{"ls"},
|
||
Short: "查看文档历史版本列表",
|
||
Example: ` dws doc version list --node DOC_ID
|
||
dws doc version list --node DOC_ID --limit 10`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
toolArgs := map[string]any{"nodeId": nodeID}
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||
toolArgs["maxResults"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "cursor", "page-token", "next-token"); v != "" {
|
||
toolArgs["nextCursor"] = v
|
||
}
|
||
return callMCPToolOnServer("doc", "list_doc_versions", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(versionListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "version_list",
|
||
CanonicalPath: "doc.version_list",
|
||
CLIPath: "doc version list",
|
||
PrimaryCLIPath: "doc version list",
|
||
},
|
||
Description: "查看文档历史版本列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "查看文档历史版本列表",
|
||
UseWhen: []string{"查看文档历史版本列表以确认版本号时"},
|
||
AvoidWhen: []string{"回滚用 version revert(需确认);保存快照用 version save"},
|
||
Examples: []string{"dws doc version list --node <DOC_ID> --format json"},
|
||
},
|
||
},
|
||
})
|
||
versionListCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
versionListCmd.Flags().Int("limit", 0, "返回版本数量上限")
|
||
versionListCmd.Flags().String("cursor", "", "分页游标")
|
||
|
||
versionRevertSafety := contract.SafetySpec{
|
||
Effect: "write",
|
||
Risk: "medium",
|
||
Confirmation: "user_required",
|
||
Idempotency: "unknown",
|
||
}
|
||
versionRevertCmd := &cobra.Command{
|
||
Use: "revert",
|
||
Short: "回滚文档到指定版本",
|
||
Example: ` dws doc version revert --node DOC_ID --version 3 --yes`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !cmd.Flags().Changed("version") {
|
||
return fmt.Errorf("flag --version is required")
|
||
}
|
||
version, _ := cmd.Flags().GetInt("version")
|
||
if !commandDryRun(cmd) {
|
||
exists, err := docVersionExists(cmd.Context(), nodeID, version)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !exists {
|
||
return apperrors.NewValidation(
|
||
fmt.Sprintf("文档版本 %d 不存在,已停止回滚", version),
|
||
apperrors.WithReason("version_not_found"),
|
||
apperrors.WithHint(fmt.Sprintf(
|
||
"请先执行 dws doc version list --node %s --format json 获取可回滚版本",
|
||
nodeID,
|
||
)),
|
||
apperrors.WithActions("查询可用文档版本", "选择存在的版本号后重新预览"),
|
||
)
|
||
}
|
||
}
|
||
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
|
||
"nodeId": nodeID,
|
||
"version": version,
|
||
})
|
||
},
|
||
}
|
||
DeclareLeafMetadata(versionRevertCmd, LeafSpec{
|
||
Safety: versionRevertSafety,
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "version_revert",
|
||
CanonicalPath: "doc.version_revert",
|
||
CLIPath: "doc version revert",
|
||
PrimaryCLIPath: "doc version revert",
|
||
},
|
||
Description: "将文档回滚到指定历史版本",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "将文档回滚到指定历史版本",
|
||
UseWhen: []string{"用户明确要求将 adoc 回滚到指定历史版本(已从 version list 确认版本号)时"},
|
||
AvoidWhen: []string{
|
||
"只看版本列表用 version list;保存快照用 save",
|
||
"版本号未确认或用户未同意回滚时不要执行",
|
||
},
|
||
Examples: []string{"dws doc version revert --node <DOC_ID> --version 3 --format json"},
|
||
},
|
||
},
|
||
})
|
||
versionRevertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||
versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)")
|
||
|
||
// version 子命令 --node 隐藏别名
|
||
for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} {
|
||
c.Flags().String("url", "", "")
|
||
c.Flags().String("id", "", "")
|
||
c.Flags().String("node-id", "", "")
|
||
c.Flags().String("doc-id", "", "")
|
||
c.Flags().String("file-id", "", "")
|
||
_ = c.Flags().MarkHidden("url")
|
||
_ = c.Flags().MarkHidden("id")
|
||
_ = c.Flags().MarkHidden("node-id")
|
||
_ = c.Flags().MarkHidden("doc-id")
|
||
_ = c.Flags().MarkHidden("file-id")
|
||
}
|
||
|
||
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
|
||
|
||
// ── template 子命令组 ──────────────────────────────────────────────────────
|
||
templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}
|
||
|
||
templateListCmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: "获取文档模板列表",
|
||
Long: `获取当前用户可用的文档模板列表,支持按来源筛选。`,
|
||
Example: ` dws doc template list
|
||
dws doc template list --source MY
|
||
dws doc template list --source PUBLIC
|
||
dws doc template list --limit 20`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
toolArgs := map[string]any{}
|
||
if v, _ := cmd.Flags().GetString("source"); v != "" {
|
||
toolArgs["templateSource"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||
toolArgs["maxResults"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "cursor", "page-token", "next-token"); v != "" {
|
||
toolArgs["nextCursor"] = v
|
||
}
|
||
return callMCPToolOnServer("doc", "list_doc_templates", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(templateListCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "template_list",
|
||
CanonicalPath: "doc.template_list",
|
||
CLIPath: "doc template list",
|
||
PrimaryCLIPath: "doc template list",
|
||
},
|
||
Description: "获取当前用户可用的文档模板列表",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "获取当前用户可用的文档模板列表",
|
||
UseWhen: []string{"列出当前用户可用的文档模板时"},
|
||
AvoidWhen: []string{"按关键词搜模板用 template search;套用模板用 template apply"},
|
||
Examples: []string{
|
||
"dws doc template list --format json",
|
||
"dws doc template list --source MY --format json",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
templateListCmd.Flags().String("source", "", "模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY")
|
||
templateListCmd.Flags().Int("limit", 0, "返回数量上限")
|
||
templateListCmd.Flags().String("cursor", "", "分页游标")
|
||
|
||
templateSearchCmd := &cobra.Command{
|
||
Use: "search",
|
||
Short: "搜索文档模板",
|
||
Long: `根据关键词搜索文档模板。`,
|
||
Example: ` dws doc template search --query "周报"
|
||
dws doc template search --query "会议纪要" --limit 10
|
||
dws doc template search --query "项目" --source PUBLIC`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
query, _ := cmd.Flags().GetString("query")
|
||
if query == "" {
|
||
query = flagOrFallback(cmd, "keyword", "name")
|
||
}
|
||
if query == "" {
|
||
return fmt.Errorf("flag --query is required")
|
||
}
|
||
toolArgs := map[string]any{
|
||
"searchName": query,
|
||
}
|
||
if v, _ := cmd.Flags().GetString("source"); v != "" {
|
||
toolArgs["templateSource"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||
toolArgs["maxResults"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "cursor", "page-token", "next-token"); v != "" {
|
||
toolArgs["nextCursor"] = v
|
||
}
|
||
return callMCPToolOnServer("doc", "search_doc_templates", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(templateSearchCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "read", Risk: "low",
|
||
Confirmation: "not_required", Idempotency: "idempotent",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "template_search",
|
||
CanonicalPath: "doc.template_search",
|
||
CLIPath: "doc template search",
|
||
PrimaryCLIPath: "doc template search",
|
||
},
|
||
Description: "根据关键词搜索文档模板",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "根据关键词搜索文档模板",
|
||
UseWhen: []string{"按关键词搜索文档模板时"},
|
||
AvoidWhen: []string{"浏览全部模板用 template list;创建文档用 template apply"},
|
||
Examples: []string{
|
||
"dws doc template search --query \"周报\" --format json",
|
||
"dws doc template search --query \"会议纪要\" --source PUBLIC --format json",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
templateSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||
templateSearchCmd.Flags().String("keyword", "", "--query 的别名")
|
||
_ = templateSearchCmd.Flags().MarkHidden("keyword")
|
||
templateSearchCmd.Flags().String("name", "", "--query 的别名")
|
||
_ = templateSearchCmd.Flags().MarkHidden("name")
|
||
templateSearchCmd.Flags().String("source", "", "模板来源: MY(我的模版)/PUBLIC(公开模版),不传默认 MY")
|
||
templateSearchCmd.Flags().Int("limit", 0, "返回数量上限")
|
||
templateSearchCmd.Flags().String("cursor", "", "分页游标")
|
||
|
||
templateApplyCmd := &cobra.Command{
|
||
Use: "apply",
|
||
Short: "应用文档模板",
|
||
Long: `使用指定模板创建新文档。`,
|
||
Example: ` dws doc template apply --template-id TPL_ID --name "我的周报"
|
||
dws doc template apply --template-id TPL_ID --name "项目方案" --folder FOLDER_ID`,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
tplID := flagOrFallback(cmd, "template-id", "template", "tpl-id")
|
||
if tplID == "" {
|
||
return fmt.Errorf("flag --template-id is required")
|
||
}
|
||
toolArgs := map[string]any{
|
||
"templateId": tplID,
|
||
}
|
||
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||
toolArgs["name"] = v
|
||
}
|
||
if v := docFolderFlag(cmd); v != "" {
|
||
toolArgs["folderId"] = v
|
||
}
|
||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
toolArgs["workspaceId"] = v
|
||
}
|
||
return callMCPToolOnServer("doc", "apply_doc_template", toolArgs)
|
||
},
|
||
}
|
||
DeclareLeafMetadata(templateApplyCmd, LeafSpec{
|
||
Safety: contract.SafetySpec{
|
||
Effect: "write", Risk: "medium",
|
||
Confirmation: "not_required", Idempotency: "unknown",
|
||
},
|
||
Contract: LeafContract{
|
||
Identity: contract.ToolIdentitySpec{
|
||
ProductID: "doc",
|
||
Name: "template_apply",
|
||
CanonicalPath: "doc.template_apply",
|
||
CLIPath: "doc template apply",
|
||
PrimaryCLIPath: "doc template apply",
|
||
},
|
||
Description: "使用指定模板创建新文档",
|
||
Interface: &contract.InterfaceSpec{
|
||
Mode: "composite",
|
||
Availability: "available",
|
||
Reason: "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||
},
|
||
Selection: contract.SelectionSpec{
|
||
AgentSummary: "使用指定模板创建新文档",
|
||
UseWhen: []string{"使用指定模板创建新文档时"},
|
||
AvoidWhen: []string{"先 list/search 拿到模板再 apply;普通空文档用 doc create"},
|
||
Examples: []string{
|
||
"dws doc template apply --template-id <TPL_ID> --name \"我的周报\" --format json",
|
||
"dws doc template apply --template-id <TPL_ID> --name \"项目方案\" --folder <FOLDER_ID> --format json",
|
||
},
|
||
},
|
||
},
|
||
})
|
||
templateApplyCmd.Flags().String("template-id", "", "模板 ID (必填)")
|
||
templateApplyCmd.Flags().String("template", "", "--template-id 的别名")
|
||
_ = templateApplyCmd.Flags().MarkHidden("template")
|
||
templateApplyCmd.Flags().String("tpl-id", "", "--template-id 的别名")
|
||
_ = templateApplyCmd.Flags().MarkHidden("tpl-id")
|
||
templateApplyCmd.Flags().String("name", "", "新文档名称 (可选)")
|
||
templateApplyCmd.Flags().String("folder", "", "目标文件夹 ID (可选)")
|
||
templateApplyCmd.Flags().String("parent-id", "", "--folder 的别名")
|
||
_ = templateApplyCmd.Flags().MarkHidden("parent-id")
|
||
templateApplyCmd.Flags().String("workspace", "", "知识库 ID (可选)")
|
||
templateApplyCmd.Flags().String("workspace-id", "", "--workspace 的别名")
|
||
_ = templateApplyCmd.Flags().MarkHidden("workspace-id")
|
||
|
||
templateCmd.AddCommand(templateListCmd, templateSearchCmd, templateApplyCmd)
|
||
|
||
// ── cross-product hidden aliases (auto-register for all leaf commands) ──
|
||
// Note: listCmd already has manually registered Int-type aliases (--max, --limit)
|
||
// and String aliases (--parent-id, --next-token) above; RegisterCrossProductAliases
|
||
// will skip flags that already exist.
|
||
for _, cmd := range []*cobra.Command{
|
||
searchCmd, listCmd, createCmd, updateCmd, uploadCmd, downloadCmd,
|
||
copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd, importCmd,
|
||
} {
|
||
RegisterCrossProductAliases(cmd)
|
||
}
|
||
// sub-commands under block/comment/media/permission/file/folder/version/template
|
||
for _, parent := range []*cobra.Command{blockCmd, commentCmd, mediaCmd, permissionCmd, fileCmd, folderCmd, versionCmd, templateCmd} {
|
||
for _, child := range parent.Commands() {
|
||
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{
|
||
copyCmd: "copy",
|
||
moveCmd: "move",
|
||
renameCmd: "rename",
|
||
uploadCmd: "upload",
|
||
downloadCmd: "download",
|
||
deleteCmd: "delete",
|
||
}
|
||
for cmd, driveCmd := range deprecatedDocToDrive {
|
||
wrapDocDeprecated(cmd, driveCmd)
|
||
cmd.Hidden = true
|
||
}
|
||
// list → drive list --workspace / wiki node list
|
||
wrapDocDeprecatedToTarget(listCmd, "drive list --workspace <workspaceId>' or 'dws wiki node list --workspace <workspaceId>")
|
||
listCmd.Hidden = true
|
||
// file create → wiki node create (空间管理层创建空文件节点)
|
||
wrapDocDeprecatedToWiki(fileCreateCmd, "wiki node create --type <type>")
|
||
// folder create → drive mkdir (钉盘) 或 wiki node create --type folder (知识库)
|
||
wrapDocDeprecatedToTarget(folderCreateCmd, "drive mkdir' or 'dws wiki node create --workspace <workspaceId> --name \"名称\" --type folder")
|
||
// permission 子命令
|
||
wrapDocDeprecated(permissionAddCmd, "permission add")
|
||
wrapDocDeprecated(permissionUpdateCmd, "permission update")
|
||
wrapDocDeprecated(permissionListCmd, "permission list")
|
||
wrapDocDeprecated(permissionRemoveCmd, "permission remove")
|
||
// search → drive search / wiki node search
|
||
wrapDocDeprecatedToTarget(searchCmd, "drive search' or 'dws wiki node search --workspace <id>")
|
||
searchCmd.Hidden = true
|
||
folderCmd.Hidden = true
|
||
permissionCmd.Hidden = true
|
||
|
||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
|
||
|
||
return root
|
||
}
|
||
|
||
// printDocDeprecationWarning emits a deprecation warning when shared deps are
|
||
// initialized. Schema declaration-only roots skip InitDeps; homology and other
|
||
// Execute probes must remain nil-safe on that path.
|
||
func printDocDeprecationWarning(msg string) {
|
||
if deps == nil || deps.Out == nil {
|
||
return
|
||
}
|
||
deps.Out.PrintWarning(msg)
|
||
}
|
||
|
||
// wrapDocDeprecated wraps a doc command's RunE to print a deprecation warning
|
||
// directing users to the corresponding drive command. The original command
|
||
// continues to function normally during the transition period.
|
||
func wrapDocDeprecated(cmd *cobra.Command, driveSubCmd string) {
|
||
originalRunE := cmd.RunE
|
||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
|
||
printDocDeprecationWarning(fmt.Sprintf(
|
||
"⚠️ 'dws doc %s' is deprecated, use 'dws drive %s' instead.",
|
||
c.CommandPath()[8:], // strip "dws doc " prefix
|
||
driveSubCmd,
|
||
))
|
||
}
|
||
return originalRunE(c, args)
|
||
}
|
||
}
|
||
|
||
// wrapDocDeprecatedToWiki wraps a doc command's RunE to print a deprecation warning
|
||
// directing users to the corresponding wiki command.
|
||
func wrapDocDeprecatedToWiki(cmd *cobra.Command, wikiSubCmd string) {
|
||
originalRunE := cmd.RunE
|
||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
|
||
printDocDeprecationWarning(fmt.Sprintf(
|
||
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
|
||
c.CommandPath()[8:],
|
||
wikiSubCmd,
|
||
))
|
||
}
|
||
return originalRunE(c, args)
|
||
}
|
||
}
|
||
|
||
// wrapDocDeprecatedToTarget wraps a doc command's RunE to print a deprecation warning
|
||
// directing users to a specified target command path.
|
||
func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
|
||
originalRunE := cmd.RunE
|
||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||
if strings.HasPrefix(c.CommandPath(), "dws doc ") {
|
||
printDocDeprecationWarning(fmt.Sprintf(
|
||
"⚠️ 'dws doc %s' is deprecated, use 'dws %s' instead.",
|
||
c.CommandPath()[8:],
|
||
targetCmd,
|
||
))
|
||
}
|
||
return originalRunE(c, args)
|
||
}
|
||
}
|
||
|
||
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
|
||
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
|
||
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer cancel()
|
||
|
||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
|
||
"nodeId": nodeID,
|
||
"format": "jsonml",
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// MCP 响应: {"nodeId":"...", "jsonml":"{...}", "revision":N, "title":"...", ...}
|
||
var mcpResp map[string]any
|
||
if err := json.Unmarshal([]byte(resultText), &mcpResp); err != nil {
|
||
return fmt.Errorf("failed to parse MCP response: %w", err)
|
||
}
|
||
jsonmlStr, _ := mcpResp["jsonml"].(string)
|
||
if jsonmlStr == "" {
|
||
return fmt.Errorf("MCP response does not contain jsonml field")
|
||
}
|
||
|
||
// 组装输出:{"revision": N, "jsonml": {...}}
|
||
outputMap := map[string]any{
|
||
"jsonml": json.RawMessage(jsonmlStr),
|
||
}
|
||
if v := mcpResp["revision"]; v != nil {
|
||
switch ver := v.(type) {
|
||
case float64:
|
||
outputMap["revision"] = int(ver)
|
||
case string:
|
||
if ver != "" {
|
||
var n int
|
||
if _, err := fmt.Sscanf(ver, "%d", &n); err == nil {
|
||
outputMap["revision"] = n
|
||
}
|
||
}
|
||
}
|
||
}
|
||
outputBytes, err := json.MarshalIndent(outputMap, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal output: %w", err)
|
||
}
|
||
output := string(outputBytes)
|
||
|
||
if outputPath != "" {
|
||
if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil {
|
||
return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
|
||
}
|
||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] JSONML 已写入 %s", outputPath))
|
||
return nil
|
||
}
|
||
|
||
deps.Out.PrintRaw(output)
|
||
return nil
|
||
}
|
||
|
||
// runDocReadScope calls get_document_content with JSONML filtering parameters
|
||
// and preserves the returned read-only fragment container.
|
||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer cancel()
|
||
|
||
args := map[string]any{"nodeId": nodeID, "format": "jsonml"}
|
||
if scope != "" {
|
||
args["scope"] = scope
|
||
}
|
||
if tags != "" {
|
||
args["tags"] = tags
|
||
}
|
||
if maxDepthSet {
|
||
args["maxDepth"] = maxDepth
|
||
}
|
||
if startBlockID != "" {
|
||
args["startBlockId"] = startBlockID
|
||
}
|
||
if endBlockID != "" && scope == "range" {
|
||
args["endBlockId"] = endBlockID
|
||
}
|
||
|
||
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var mcpResp map[string]any
|
||
if err := json.Unmarshal([]byte(resultText), &mcpResp); err != nil {
|
||
return fmt.Errorf("failed to parse MCP response: %w", err)
|
||
}
|
||
fragmentJSON, _ := mcpResp["jsonml"].(string)
|
||
if fragmentJSON == "" {
|
||
if deps.Caller.Format() == "json" {
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"matched": false,
|
||
"jsonml": nil,
|
||
})
|
||
}
|
||
deps.Out.PrintInfo("[INFO] 未匹配到节点")
|
||
return nil
|
||
}
|
||
if !json.Valid([]byte(fragmentJSON)) {
|
||
return fmt.Errorf("MCP response contained invalid JSONML fragment")
|
||
}
|
||
|
||
output := fragmentJSON
|
||
if pretty, prettyErr := json.MarshalIndent(json.RawMessage(fragmentJSON), "", " "); prettyErr == nil {
|
||
output = string(pretty)
|
||
}
|
||
|
||
if outputPath != "" {
|
||
if err := os.WriteFile(outputPath, []byte(output), 0o644); err != nil {
|
||
return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
|
||
}
|
||
if deps.Caller.Format() == "json" {
|
||
return deps.Out.PrintJSON(map[string]any{
|
||
"success": true,
|
||
"output": outputPath,
|
||
})
|
||
}
|
||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] JSONML fragment 已写入 %s", outputPath))
|
||
return nil
|
||
}
|
||
|
||
deps.Out.PrintRaw(output)
|
||
return nil
|
||
}
|
||
|
||
// resolveContentFromFlags 从 --content-file / --content / --markdown 获取文档内容。
|
||
// 优先级:--content-file > --content > --markdown(已弃用别名,向后兼容)。
|
||
//
|
||
// --content-file path → 从文件读取(UTF-8)
|
||
// --content - → 从 stdin 读取
|
||
// --content "x" → 字面值
|
||
// --markdown "x" → 已弃用,等同于 --content
|
||
func resolveContentFromFlags(cmd *cobra.Command) (string, error) {
|
||
filePath := flagOrFallback(cmd, "content-file", "content-path")
|
||
if filePath != "" {
|
||
b, err := os.ReadFile(filePath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("--content-file: 读取文件 %q 失败: %w", filePath, err)
|
||
}
|
||
return string(b), nil
|
||
}
|
||
|
||
raw, _ := cmd.Flags().GetString("content")
|
||
if raw == "-" {
|
||
data, err := io.ReadAll(cmd.InOrStdin())
|
||
if err != nil {
|
||
return "", fmt.Errorf("--content: 读取 stdin 失败: %w", err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
// --markdown 是 --content 的已弃用别名,仅在 --content 和 --content-file 都未提供时 fallback
|
||
if raw == "" {
|
||
if md, _ := cmd.Flags().GetString("markdown"); md != "" {
|
||
if md == "-" {
|
||
data, err := io.ReadAll(cmd.InOrStdin())
|
||
if err != nil {
|
||
return "", fmt.Errorf("--markdown: 读取 stdin 失败: %w", err)
|
||
}
|
||
return string(data), nil
|
||
}
|
||
return unescapeLiteralContent(md), nil
|
||
}
|
||
}
|
||
|
||
return unescapeLiteralContent(raw), nil
|
||
}
|
||
|
||
// unescapeLiteralContent 将命令行字面量中的转义序列转换为对应字符。
|
||
// 使用 strconv.Unquote 处理所有标准转义: \n \t \r \\ \uXXXX 等。
|
||
// 仅用于 --content / --markdown 字面量输入场景;文件和 stdin 输入已含真实换行,无需处理。
|
||
// 当发生转义时会打印 warning 提示用户。
|
||
func unescapeLiteralContent(s string) string {
|
||
if s == "" || !strings.Contains(s, "\\") {
|
||
return s
|
||
}
|
||
// strconv.Unquote 要求输入用双引号包裹,且内部双引号需转义
|
||
quoted := `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
|
||
unquoted, err := strconv.Unquote(quoted)
|
||
if err != nil {
|
||
// 解析失败则原样返回,不破坏用户输入
|
||
return s
|
||
}
|
||
if unquoted != s {
|
||
deps.Out.PrintInfo("[WARN] 检测到转义序列 (\\n, \\t 等),已自动转换为对应字符。如需保留字面反斜杠,请使用 \\\\ 或改用 --content-file")
|
||
}
|
||
return unquoted
|
||
}
|
||
|
||
// pollExportJob polls query_export_job with progressive back-off until the
|
||
// task completes or the retry limit is reached.
|
||
//
|
||
// Back-off schedule (aligned with lippi-doc-solution server-side guidance):
|
||
//
|
||
// polls 1-5: 2s interval
|
||
// polls 6-10: 5s interval
|
||
// polls 11-20: 10s interval
|
||
// polls 21-30: 15s interval
|
||
// max 30 polls (~5 minutes total)
|
||
func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, err error) {
|
||
const maxPolls = 30
|
||
|
||
pollInterval := func(attempt int) time.Duration {
|
||
switch {
|
||
case attempt <= 5:
|
||
return 2 * time.Second
|
||
case attempt <= 10:
|
||
return 5 * time.Second
|
||
case attempt <= 20:
|
||
return 10 * time.Second
|
||
default:
|
||
return 15 * time.Second
|
||
}
|
||
}
|
||
|
||
for attempt := 1; attempt <= maxPolls; attempt++ {
|
||
interval := pollInterval(attempt)
|
||
printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return "", fmt.Errorf("导出轮询被取消 (jobId=%s): %w", jobID, ctx.Err())
|
||
case <-helperAfter(interval):
|
||
}
|
||
|
||
text, queryErr := callMCPToolReturnText(ctx, "query_export_job", map[string]any{"jobId": jobID})
|
||
if queryErr != nil {
|
||
return "", fmt.Errorf("查询导出任务失败 (jobId=%s): %w", jobID, queryErr)
|
||
}
|
||
|
||
var result map[string]any
|
||
if parseErr := json.Unmarshal([]byte(text), &result); parseErr != nil {
|
||
return "", fmt.Errorf("解析查询结果失败 (jobId=%s): %w", jobID, parseErr)
|
||
}
|
||
|
||
status, _ := result["status"].(string)
|
||
message, _ := result["message"].(string)
|
||
normalizedStatus := strings.ToUpper(status)
|
||
|
||
switch normalizedStatus {
|
||
case "SUCCESS":
|
||
url, _ := result["downloadUrl"].(string)
|
||
if url == "" {
|
||
return "", fmt.Errorf("导出成功但 downloadUrl 为空 (jobId=%s)", jobID)
|
||
}
|
||
return url, nil
|
||
case "PROCESSING":
|
||
continue
|
||
default:
|
||
if message != "" {
|
||
return "", fmt.Errorf("导出任务失败 (jobId=%s, status=%s): %s", jobID, status, message)
|
||
}
|
||
return "", fmt.Errorf("导出任务失败 (jobId=%s, status=%s)", jobID, status)
|
||
}
|
||
}
|
||
|
||
return "", fmt.Errorf("导出任务超时:已轮询 %d 次仍在处理中 (jobId=%s),请稍后使用 dws doc export get --job-id %s 手动查询", maxPolls, jobID, jobID)
|
||
}
|
||
|
||
// stripDuplicateTitle removes the leading H1 heading from markdown content
|
||
// when it matches the document name (set via --name). This prevents the title
|
||
// from appearing twice: once as document metadata and once in the body.
|
||
func stripDuplicateTitle(markdown, name string) string {
|
||
trimmed := strings.TrimLeft(markdown, " \t\n\r")
|
||
if !strings.HasPrefix(trimmed, "# ") {
|
||
return markdown
|
||
}
|
||
newlineIdx := strings.Index(trimmed, "\n")
|
||
var headingRaw string
|
||
if newlineIdx < 0 {
|
||
headingRaw = trimmed[2:]
|
||
} else {
|
||
headingRaw = trimmed[2:newlineIdx]
|
||
}
|
||
|
||
if normalizeHeadingText(headingRaw) != normalizeHeadingText(name) {
|
||
return markdown
|
||
}
|
||
|
||
if newlineIdx < 0 {
|
||
return ""
|
||
}
|
||
rest := trimmed[newlineIdx+1:]
|
||
rest = strings.TrimLeft(rest, "\n")
|
||
return rest
|
||
}
|
||
|
||
// normalizeHeadingText strips trailing ATX hashes, inline markdown formatting
|
||
// markers, then returns a lowercased, trimmed string for comparison.
|
||
func normalizeHeadingText(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
if i := strings.LastIndexByte(s, ' '); i >= 0 {
|
||
suffix := s[i+1:]
|
||
if len(suffix) > 0 && strings.Trim(suffix, "#") == "" {
|
||
s = strings.TrimSpace(s[:i])
|
||
}
|
||
}
|
||
for _, m := range []string{"**", "__", "~~", "*", "_", "`"} {
|
||
s = strings.ReplaceAll(s, m, "")
|
||
}
|
||
return strings.TrimSpace(strings.ToLower(s))
|
||
}
|
||
|
||
// parseCommentMentionIds splits a comma-separated string of user IDs into a slice.
|
||
func parseCommentMentionIds(raw string) []string {
|
||
parts := strings.Split(raw, ",")
|
||
userIds := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
uid := strings.TrimSpace(p)
|
||
if uid != "" {
|
||
userIds = append(userIds, uid)
|
||
}
|
||
}
|
||
return userIds
|
||
}
|
||
|
||
func addCommentGroupMentionFlag(cmd *cobra.Command) {
|
||
cmd.Flags().StringSlice(
|
||
"mentioned-open-conversation-id",
|
||
nil,
|
||
"被 @ 的群 openConversationId,可重复指定或逗号分隔",
|
||
)
|
||
}
|
||
|
||
// commentGroupMentionIDs validates, trims and stably de-duplicates group IDs.
|
||
// An explicitly supplied blank value is rejected so a requested mention is
|
||
// never silently downgraded to plain comment text.
|
||
func commentGroupMentionIDs(cmd *cobra.Command) ([]string, error) {
|
||
raw, err := cmd.Flags().GetStringSlice("mentioned-open-conversation-id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !cmd.Flags().Changed("mentioned-open-conversation-id") {
|
||
return nil, nil
|
||
}
|
||
|
||
seen := make(map[string]struct{}, len(raw))
|
||
ids := make([]string, 0, len(raw))
|
||
for _, value := range raw {
|
||
id := strings.TrimSpace(value)
|
||
if id == "" {
|
||
return nil, fmt.Errorf("--mentioned-open-conversation-id must not be empty or whitespace")
|
||
}
|
||
if _, exists := seen[id]; exists {
|
||
continue
|
||
}
|
||
seen[id] = struct{}{}
|
||
ids = append(ids, id)
|
||
}
|
||
if len(ids) == 0 {
|
||
return nil, fmt.Errorf("--mentioned-open-conversation-id must include at least one non-empty openConversationId")
|
||
}
|
||
return ids, nil
|
||
}
|
||
|
||
func appendCommentGroupMentions(cmd *cobra.Command, args map[string]any) error {
|
||
ids, err := commentGroupMentionIDs(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(ids) > 0 {
|
||
args["mentionedOpenConversationIds"] = ids
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// normalizePermissionRole canonicalises the --role flag to UPPERCASE so users
|
||
// can pass either "reader" or "READER". Trims whitespace as well.
|
||
// Empty input returns "" so the caller can validate as needed.
|
||
func normalizePermissionRole(raw string) string {
|
||
return strings.ToUpper(strings.TrimSpace(raw))
|
||
}
|
||
|
||
// parseRoleList splits a comma-separated role list and uppercases each item.
|
||
// Used by --filter-role for list_permission / list_member.
|
||
func parseRoleList(raw string) []string {
|
||
parts := strings.Split(raw, ",")
|
||
roles := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
role := normalizePermissionRole(p)
|
||
if role != "" {
|
||
roles = append(roles, role)
|
||
}
|
||
}
|
||
return roles
|
||
}
|
||
|
||
// collectUserIDs reads the comma-separated --user flag and returns a flat
|
||
// userIds slice, ready to embed in the MCP tool args (add/update_permission
|
||
// and add/update_member all share this shape).
|
||
//
|
||
// MCP tools currently only accept the USER member type — ORG-level grants
|
||
// are blocked at the MCP gateway, so the dws layer does not need to filter
|
||
// or wrap members itself.
|
||
func collectUserIDs(cmd *cobra.Command) ([]string, error) {
|
||
userRaw := flagOrFallback(cmd, "users", "user")
|
||
userIds := parseCommentMentionIds(userRaw)
|
||
if len(userIds) == 0 {
|
||
return nil, fmt.Errorf("--users is required (at least one userId)")
|
||
}
|
||
return userIds, nil
|
||
}
|