Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2685 lines
90 KiB
Go
2685 lines
90 KiB
Go
// Copyright 2026 Alibaba Group
|
||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||
// you may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// http://www.apache.org/licenses/LICENSE-2.0
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/asynctask"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
func init() {
|
||
RegisterPublic(func() Handler {
|
||
return docHandler{}
|
||
})
|
||
}
|
||
|
||
type docHandler struct{}
|
||
|
||
func (docHandler) Name() string {
|
||
return "doc"
|
||
}
|
||
|
||
func (docHandler) Command(runner executor.Runner) *cobra.Command {
|
||
root := &cobra.Command{
|
||
Use: "doc",
|
||
Short: i18n.T("钉钉文档操作"),
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
|
||
media := &cobra.Command{
|
||
Use: "media",
|
||
Short: i18n.T("文档媒体 / 附件管理"),
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
media.AddCommand(newDocMediaDownloadCommand(runner))
|
||
media.AddCommand(newDocMediaInsertCommand(runner))
|
||
|
||
permission := &cobra.Command{
|
||
Use: "permission",
|
||
Short: i18n.T("文档权限协作管理(节点级)"),
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
permission.AddCommand(
|
||
newDocPermissionAddCommand(runner),
|
||
newDocPermissionUpdateCommand(runner),
|
||
newDocPermissionListCommand(runner),
|
||
newDocPermissionRemoveCommand(runner),
|
||
)
|
||
|
||
export := &cobra.Command{
|
||
Use: "export",
|
||
Short: i18n.T("文档导出(异步任务)"),
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runDocExport(cmd, runner)
|
||
},
|
||
}
|
||
export.AddCommand(newDocExportGetCommand(runner))
|
||
|
||
file := newDocGroup("file", i18n.T("文档文件管理"))
|
||
file.AddCommand(newDocFileCreateCommand(runner))
|
||
|
||
folder := newDocGroup("folder", i18n.T("文档文件夹管理"))
|
||
folder.AddCommand(newDocFolderCreateCommand(runner))
|
||
|
||
block := newDocGroup("block", i18n.T("文档块管理"))
|
||
block.AddCommand(
|
||
newDocBlockListCommand(runner),
|
||
newDocBlockInsertCommand(runner),
|
||
newDocBlockUpdateCommand(runner),
|
||
newDocBlockDeleteCommand(runner),
|
||
)
|
||
|
||
comment := newDocGroup("comment", i18n.T("文档评论管理"))
|
||
comment.AddCommand(
|
||
newDocCommentListCommand(runner),
|
||
newDocCommentCreateCommand(runner),
|
||
newDocCommentReplyCommand(runner),
|
||
newDocCommentCreateInlineCommand(runner),
|
||
)
|
||
|
||
root.AddCommand(file)
|
||
root.AddCommand(folder)
|
||
root.AddCommand(block)
|
||
root.AddCommand(comment)
|
||
root.AddCommand(media)
|
||
root.AddCommand(permission)
|
||
root.AddCommand(export)
|
||
root.AddCommand(newDocSearchCommand(runner))
|
||
root.AddCommand(newDocListCommand(runner))
|
||
root.AddCommand(newDocInfoCommand(runner))
|
||
root.AddCommand(newDocReadCommand(runner))
|
||
root.AddCommand(newDocCreateCommand(runner))
|
||
root.AddCommand(newDocUpdateCommand(runner))
|
||
root.AddCommand(newDocUploadCommand(runner))
|
||
root.AddCommand(newDocDownloadCommand(runner))
|
||
root.AddCommand(newDocCopyCommand(runner))
|
||
root.AddCommand(newDocMoveCommand(runner))
|
||
root.AddCommand(newDocRenameCommand(runner))
|
||
root.AddCommand(newDocDeleteCommand(runner))
|
||
preferLegacyLeaf(export) // export 同时是一体化命令本身(含 RunE)
|
||
doRegisterDocExportFlags(export)
|
||
return root
|
||
}
|
||
|
||
func newDocGroup(use, short string) *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: use,
|
||
Short: short,
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
}
|
||
|
||
// doRegisterDocExportFlags 把 export 一体化命令的 flag 注册分离出来,
|
||
// 便于和 export get 子命令的 flag 分离管理。
|
||
func doRegisterDocExportFlags(cmd *cobra.Command) {
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("output", "", i18n.T("本地落盘路径(必填;传目录时自动生成 docx 文件名)"))
|
||
cmd.Flags().String("export-format", "", i18n.T("导出格式,当前仅支持 docx(悟空兼容)"))
|
||
cmd.Flags().Int("timeout-sec", 300, i18n.T("整体轮询超时(秒),默认 300"))
|
||
}
|
||
|
||
func newDocSearchCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "search",
|
||
Short: i18n.T("搜索文档"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive search or dws wiki node search")
|
||
params := map[string]any{}
|
||
if keyword := docFlagOrFallback(cmd, "query", "keyword"); keyword != "" {
|
||
params["keyword"] = keyword
|
||
}
|
||
addDocCSVParam(cmd, params, "extensions", "extensions")
|
||
addDocIntParam(cmd, params, "createdTimeFrom", "created-from")
|
||
addDocIntParam(cmd, params, "createdTimeTo", "created-to")
|
||
addDocIntParam(cmd, params, "visitedTimeFrom", "visited-from")
|
||
addDocIntParam(cmd, params, "visitedTimeTo", "visited-to")
|
||
addDocCSVParam(cmd, params, "creatorUserIds", "creator-uids")
|
||
addDocCSVParam(cmd, params, "editorUserIds", "editor-uids")
|
||
addDocCSVParam(cmd, params, "mentionedUserIds", "mentioned-uids")
|
||
addDocCSVParam(cmd, params, "workspaceIds", "workspace-ids", "workspace")
|
||
if pageSize := docIntFlagOrFallback(cmd, "page-size", "limit"); pageSize > 0 {
|
||
params["pageSize"] = pageSize
|
||
}
|
||
addDocStringParam(cmd, params, "pageToken", "page-token", "cursor")
|
||
return runDocTool(cmd, runner, "doc", "search_documents", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("query", "", i18n.T("搜索关键词"))
|
||
addDocHiddenStringFlag(cmd, "keyword", "--query alias")
|
||
cmd.Flags().String("extensions", "", i18n.T("扩展名,逗号分隔"))
|
||
cmd.Flags().Int64("created-from", 0, i18n.T("创建时间起点"))
|
||
cmd.Flags().Int64("created-to", 0, i18n.T("创建时间终点"))
|
||
cmd.Flags().Int64("visited-from", 0, i18n.T("访问时间起点"))
|
||
cmd.Flags().Int64("visited-to", 0, i18n.T("访问时间终点"))
|
||
cmd.Flags().String("creator-uids", "", i18n.T("创建人 userId,逗号分隔"))
|
||
cmd.Flags().String("editor-uids", "", i18n.T("编辑人 userId,逗号分隔"))
|
||
cmd.Flags().String("mentioned-uids", "", i18n.T("提及 userId,逗号分隔"))
|
||
cmd.Flags().String("workspace-ids", "", i18n.T("Workspace ID,逗号分隔"))
|
||
addDocHiddenStringFlag(cmd, "workspace", "--workspace-ids alias")
|
||
cmd.Flags().Int("page-size", 0, i18n.T("每页数量"))
|
||
cmd.Flags().Int("limit", 0, i18n.T("--page-size 的悟空兼容别名"))
|
||
_ = cmd.Flags().MarkHidden("limit")
|
||
cmd.Flags().String("page-token", "", i18n.T("分页 token"))
|
||
addDocHiddenStringFlag(cmd, "cursor", "--page-token 的悟空兼容别名")
|
||
return cmd
|
||
}
|
||
|
||
func newDocListCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: i18n.T("列出文档节点"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive list or dws wiki node list")
|
||
params := map[string]any{}
|
||
if folder := docFlagOrFallback(cmd, "folder", "parent-id", "node", "file-id", "nodee"); folder != "" {
|
||
params["folderId"] = normalizeDocNodeID(folder)
|
||
}
|
||
if workspace := docFlagOrFallback(cmd, "workspace", "workspace-id"); workspace != "" {
|
||
params["workspaceId"] = workspace
|
||
}
|
||
if pageSize := docIntFlagOrFallback(cmd, "page-size", "limit"); pageSize > 0 {
|
||
params["pageSize"] = pageSize
|
||
}
|
||
addDocStringParam(cmd, params, "pageToken", "page-token", "cursor")
|
||
return runDocTool(cmd, runner, "doc", "list_nodes", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("folder", "", i18n.T("父文件夹 nodeId / URL"))
|
||
addDocHiddenStringFlag(cmd, "parent-id", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "node", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "file-id", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "nodee", "--folder alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("Workspace ID"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
cmd.Flags().Int("page-size", 0, i18n.T("每页数量"))
|
||
cmd.Flags().Int("limit", 0, i18n.T("--page-size 的悟空兼容别名"))
|
||
_ = cmd.Flags().MarkHidden("limit")
|
||
cmd.Flags().String("page-token", "", i18n.T("分页 token"))
|
||
addDocHiddenStringFlag(cmd, "cursor", "--page-token 的悟空兼容别名")
|
||
return cmd
|
||
}
|
||
|
||
func newDocReadCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "read",
|
||
Short: i18n.T("读取文档内容"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
format, err := docContentFormat(cmd, "markdown", "jsonml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if format != "" {
|
||
params["format"] = format
|
||
}
|
||
if output := docStringFlag(cmd, "output"); output != "" {
|
||
params["__output__"] = output
|
||
}
|
||
if format == "jsonml" {
|
||
return runDocReadJSONML(cmd, runner, params)
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "get_document_content", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("content-format", "", i18n.T("输出格式: markdown / jsonml"))
|
||
cmd.Flags().String("output", "", i18n.T("输出到本地文件路径(JSONML 场景透传)"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocInfoCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "info",
|
||
Short: i18n.T("获取文档元信息"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "get_document_info", map[string]any{"nodeId": nodeID})
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
return cmd
|
||
}
|
||
|
||
func newDocCreateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: i18n.T("创建文档"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"name": name}
|
||
if folder := docFlagOrFallback(cmd, "folder", "parent-id", "parent-folder-id", "parent-folder"); folder != "" {
|
||
params["folderId"] = normalizeDocNodeID(folder)
|
||
}
|
||
if workspace := docFlagOrFallback(cmd, "workspace", "workspace-id"); workspace != "" {
|
||
params["workspaceId"] = workspace
|
||
}
|
||
content, err := docResolveContent(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if content != "" {
|
||
format, err := docContentFormat(cmd, "markdown", "jsonml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if format == "jsonml" {
|
||
jsonml, err := prepareDocJSONMLBody(cmd, content)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if stripped, ok := stripLeadingDuplicateTitleJSONML(jsonml, name); ok {
|
||
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首个与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
|
||
jsonml = stripped
|
||
}
|
||
createResult, err := docInvocationResult(cmd, runner, "doc", "create_document", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
nodeID := docFirstStringRecursive(createResult.Response, "nodeId", "nodeID", "id")
|
||
if nodeID == "" {
|
||
return apperrors.NewValidation("创建文档成功但无法提取 nodeId")
|
||
}
|
||
updateResult, err := docInvocationResult(cmd, runner, "doc", "update_document", map[string]any{
|
||
"nodeId": nodeID,
|
||
"format": "jsonml",
|
||
"jsonml": jsonml,
|
||
"mode": "overwrite",
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, updateResult)
|
||
} else {
|
||
if sniffJsonMLLike(content) {
|
||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
|
||
}
|
||
if stripped, ok := stripLeadingDuplicateTitleHeading(content, name); ok {
|
||
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首行与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
|
||
content = stripped
|
||
}
|
||
if content != "" {
|
||
params["markdown"] = stripDocInputUnsafe(content)
|
||
}
|
||
}
|
||
} else if format, err := docContentFormat(cmd, "markdown", "jsonml"); err != nil {
|
||
return err
|
||
} else if format != "" {
|
||
params["format"] = format
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "create_document", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("name", "", i18n.T("文档名称 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "title", "--name alias")
|
||
cmd.Flags().String("folder", "", i18n.T("父文件夹 nodeId / URL"))
|
||
addDocHiddenStringFlag(cmd, "parent-id", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "parent-folder-id", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "parent-folder", "--folder alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("Workspace ID"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
cmd.Flags().String("content", "", i18n.T("初始 Markdown 内容"))
|
||
cmd.Flags().String("content-file", "", i18n.T("从文件读取初始 Markdown"))
|
||
addDocHiddenStringFlag(cmd, "content-path", "--content-file alias")
|
||
addDocHiddenStringFlag(cmd, "markdown", "--content alias")
|
||
cmd.Flags().String("content-format", "", i18n.T("内容格式: markdown / jsonml"))
|
||
addDocJSONMLControlFlags(cmd)
|
||
return cmd
|
||
}
|
||
|
||
// stripLeadingDuplicateTitleHeading removes a leading markdown ATX H1 whose
|
||
// text equals the document name. The platform already renders the document
|
||
// name as the page title, so a body that opens with the same H1 displays the
|
||
// title twice (the "two headings" effect). Only an exact match (trimmed,
|
||
// case-insensitive) is removed; any other leading H1 is kept as intentional
|
||
// content. Reports whether a heading was stripped.
|
||
func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return content, false
|
||
}
|
||
body := strings.TrimLeft(content, "\ufeff \t\r\n")
|
||
line := body
|
||
rest := ""
|
||
if idx := strings.IndexByte(body, '\n'); idx >= 0 {
|
||
line = body[:idx]
|
||
rest = body[idx+1:]
|
||
}
|
||
line = strings.TrimRight(line, " \t\r")
|
||
if !strings.HasPrefix(line, "# ") {
|
||
return content, false
|
||
}
|
||
heading := strings.TrimSpace(line[2:])
|
||
// ATX closing sequence ("# Title #") — only trim trailing hashes when
|
||
// separated by a space, so names that legitimately end with '#' survive.
|
||
if t := strings.TrimRight(heading, "#"); t != heading && strings.HasSuffix(t, " ") {
|
||
heading = strings.TrimSpace(t)
|
||
}
|
||
if !strings.EqualFold(heading, name) {
|
||
return content, false
|
||
}
|
||
return strings.TrimLeft(rest, "\r\n"), true
|
||
}
|
||
|
||
// stripLeadingDuplicateTitleJSONML is the JSONML-path counterpart of
|
||
// stripLeadingDuplicateTitleHeading. The rich-content path (--content-format
|
||
// jsonml) writes the body verbatim via update_document, so a leading h1 node
|
||
// whose text equals the document name renders the title twice — exactly the
|
||
// problem the markdown path already guards against.
|
||
//
|
||
// jsonmlBody is the marshaled body produced by prepareDocJSONMLBody: a JSON
|
||
// array, either a bare node list or wrapped as ["root", {}, ...nodes]. The
|
||
// first content node is removed only when it is an h1 whose concatenated text
|
||
// (trimmed, case-insensitive) exactly equals name. Any parse failure or
|
||
// non-match returns the input untouched, so this never blocks a valid write.
|
||
func stripLeadingDuplicateTitleJSONML(jsonmlBody, name string) (string, bool) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return jsonmlBody, false
|
||
}
|
||
var body []any
|
||
if err := json.Unmarshal([]byte(jsonmlBody), &body); err != nil {
|
||
return jsonmlBody, false
|
||
}
|
||
// Locate the index of the first content node, skipping a ["root", {}, ...]
|
||
// wrapper if present.
|
||
first := 0
|
||
if len(body) >= 2 {
|
||
if tag, ok := body[0].(string); ok && tag == "root" {
|
||
first = 2
|
||
}
|
||
}
|
||
if first >= len(body) {
|
||
return jsonmlBody, false
|
||
}
|
||
node, ok := body[first].([]any)
|
||
if !ok || len(node) == 0 {
|
||
return jsonmlBody, false
|
||
}
|
||
tag, ok := node[0].(string)
|
||
if !ok || tag != "h1" {
|
||
return jsonmlBody, false
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(jsonmlNodeText(node)), name) {
|
||
return jsonmlBody, false
|
||
}
|
||
body = append(body[:first], body[first+1:]...)
|
||
out, err := json.Marshal(body)
|
||
if err != nil {
|
||
return jsonmlBody, false
|
||
}
|
||
return string(out), true
|
||
}
|
||
|
||
// jsonmlNodeText concatenates all string leaves of a JSONML node, skipping the
|
||
// leading tag name and any attributes object, so ["h1", {..}, ["span", {..},
|
||
// ["span", {..}, "Title"]]] yields "Title".
|
||
func jsonmlNodeText(node any) string {
|
||
switch v := node.(type) {
|
||
case string:
|
||
return v
|
||
case []any:
|
||
var b strings.Builder
|
||
for i, child := range v {
|
||
// element[0] is the tag name; a map at element[1] is attributes.
|
||
if i == 0 {
|
||
if _, isStr := child.(string); isStr {
|
||
continue
|
||
}
|
||
}
|
||
if _, isMap := child.(map[string]any); isMap {
|
||
continue
|
||
}
|
||
b.WriteString(jsonmlNodeText(child))
|
||
}
|
||
return b.String()
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: i18n.T("更新文档内容"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
mode, err := docRequiredFlag(cmd, "mode")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
content, err := docResolveContent(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if content == "" {
|
||
return apperrors.NewValidation("--content or --content-file is required")
|
||
}
|
||
if docModeRequiresYes(mode) && !docYesFlag(cmd) && !commandDryRun(cmd) {
|
||
return apperrors.NewValidation("--mode overwrite requires --yes unless --dry-run is set")
|
||
}
|
||
params := map[string]any{
|
||
"nodeId": nodeID,
|
||
"mode": strings.TrimSpace(mode),
|
||
}
|
||
format, err := docContentFormat(cmd, "markdown", "jsonml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if format == "jsonml" {
|
||
if strings.TrimSpace(mode) == "append" {
|
||
return apperrors.NewValidation("--content-format jsonml 当前仅支持 --mode overwrite,append 模式将在后续版本支持")
|
||
}
|
||
jsonml, err := prepareDocJSONMLBody(cmd, content)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params["format"] = "jsonml"
|
||
params["jsonml"] = jsonml
|
||
addDocIntParam(cmd, params, "revision", "revision")
|
||
} else {
|
||
if sniffJsonMLLike(content) {
|
||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: 输入内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 markdown 解析。`)
|
||
}
|
||
params["markdown"] = stripDocInputUnsafe(content)
|
||
addDocIntParam(cmd, params, "index", "index")
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "update_document", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("content", "", i18n.T("Markdown 内容"))
|
||
cmd.Flags().String("content-file", "", i18n.T("从文件读取 Markdown"))
|
||
addDocHiddenStringFlag(cmd, "content-path", "--content-file alias")
|
||
addDocHiddenStringFlag(cmd, "markdown", "--content alias")
|
||
cmd.Flags().String("mode", "", i18n.T("更新模式: append / overwrite"))
|
||
cmd.Flags().Int("index", 0, i18n.T("append 插入位置"))
|
||
cmd.Flags().String("content-format", "", i18n.T("内容格式: markdown / jsonml"))
|
||
cmd.Flags().Int("revision", 0, i18n.T("文档编辑版本号(JSONML 场景透传)"))
|
||
addDocJSONMLControlFlags(cmd)
|
||
return cmd
|
||
}
|
||
|
||
func newDocDownloadCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "download",
|
||
Short: i18n.T("获取文档下载地址"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := docRequiredFlag(cmd, "output"); err != nil {
|
||
return err
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "download_file", map[string]any{"nodeId": nodeID})
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("output", "", i18n.T("本地输出路径 (必填)"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocUploadCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "upload",
|
||
Short: i18n.T("上传文件到钉钉文档或知识库"),
|
||
Long: i18n.T(`将本地文件上传到钉钉文档或知识库(三步自动完成)。
|
||
|
||
流程:
|
||
1. 获取上传凭证 (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`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runDocUpload(cmd, runner)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("file", "", i18n.T("本地文件路径 (必填)"))
|
||
cmd.Flags().String("name", "", i18n.T("文件显示名称(默认使用本地文件名)"))
|
||
cmd.Flags().String("folder", "", i18n.T("目标文档文件夹 nodeId 或 alidocs 文件夹 URL"))
|
||
addDocHiddenStringFlag(cmd, "parent-id", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "parent-folder", "--folder alias")
|
||
addDocHiddenStringFlag(cmd, "parent-folder-id", "--folder alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
cmd.Flags().Bool("convert", false, i18n.T("是否转换为钉钉在线文档"))
|
||
return cmd
|
||
}
|
||
|
||
func runDocUpload(cmd *cobra.Command, runner executor.Runner) error {
|
||
filePath, err := docRequiredFlag(cmd, "file")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
absPath, err := filepath.Abs(filePath)
|
||
if err != nil {
|
||
return apperrors.NewValidation(i18n.T("无法解析文件路径: ") + err.Error())
|
||
}
|
||
info, err := os.Stat(absPath)
|
||
if err != nil {
|
||
return apperrors.NewValidation(i18n.T("文件不存在: ") + absPath)
|
||
}
|
||
if info.IsDir() {
|
||
return apperrors.NewValidation(i18n.T("不是文件: ") + absPath)
|
||
}
|
||
fileSize := info.Size()
|
||
if fileSize <= 0 {
|
||
return apperrors.NewValidation(i18n.T("文件为空"))
|
||
}
|
||
|
||
name := docStringFlag(cmd, "name")
|
||
if name == "" {
|
||
name = filepath.Base(absPath)
|
||
} else if filepath.Ext(name) == "" {
|
||
if ext := filepath.Ext(absPath); ext != "" {
|
||
name += ext
|
||
}
|
||
}
|
||
|
||
folder := docFlagOrFallback(cmd, "folder", "parent-id", "parent-folder", "parent-folder-id")
|
||
if folder != "" {
|
||
folder = normalizeDocNodeID(folder)
|
||
}
|
||
workspace := docFlagOrFallback(cmd, "workspace", "workspace-id")
|
||
step1Params := map[string]any{}
|
||
if folder != "" {
|
||
step1Params["folderId"] = folder
|
||
}
|
||
if workspace != "" {
|
||
step1Params["workspaceId"] = workspace
|
||
}
|
||
|
||
commitParams := map[string]any{
|
||
"name": name,
|
||
"fileSize": float64(fileSize),
|
||
}
|
||
if folder != "" {
|
||
commitParams["folderId"] = folder
|
||
}
|
||
if workspace != "" {
|
||
commitParams["workspaceId"] = workspace
|
||
}
|
||
if convert, _ := cmd.Flags().GetBool("convert"); convert {
|
||
commitParams["convertToOnlineDoc"] = true
|
||
}
|
||
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, map[string]any{
|
||
"dry_run": true,
|
||
"step_1_get_file_upload_info": executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
|
||
),
|
||
"step_2_http_put_oss": "PUT file bytes to resourceUrl with returned headers",
|
||
"step_3_commit_uploaded_file": executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
|
||
),
|
||
"file": absPath,
|
||
"name": name,
|
||
"size": fileSize,
|
||
})
|
||
}
|
||
|
||
progressOut := cmd.ErrOrStderr()
|
||
fmt.Fprintf(progressOut, i18n.T("[1/3] 获取文件上传凭证 (%s, %d 字节)...\n"), name, fileSize)
|
||
step1Result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
|
||
))
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("获取上传凭证失败: %w"), err)
|
||
}
|
||
resourceURL, uploadKey, headers, err := extractDocFileUploadInfo(step1Result.Response)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Fprintln(progressOut, i18n.T("[2/3] 上传文件到 OSS..."))
|
||
if err := httpPutDriveFile(cmd.Context(), resourceURL, headers, absPath, fileSize); err != nil {
|
||
return err
|
||
}
|
||
|
||
fmt.Fprintln(progressOut, i18n.T("[3/3] 提交文件入库..."))
|
||
commitParams["uploadKey"] = uploadKey
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
|
||
))
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("提交文件入库失败: %w"), err)
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
}
|
||
|
||
func extractDocFileUploadInfo(resp map[string]any) (resourceURL, uploadKey string, headers map[string]string, err error) {
|
||
data := unwrapDocUploadResp(resp)
|
||
resourceURL, _ = data["resourceUrl"].(string)
|
||
if resourceURL == "" {
|
||
resourceURL, _ = data["uploadUrl"].(string)
|
||
}
|
||
if resourceURL == "" {
|
||
if urls, ok := data["resourceUrls"].([]any); ok && len(urls) > 0 {
|
||
if first, ok := urls[0].(map[string]any); ok {
|
||
resourceURL, _ = first["url"].(string)
|
||
if len(headers) == 0 {
|
||
headers = stringMapFromAny(first["headers"])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
uploadKey, _ = data["uploadKey"].(string)
|
||
if uploadKey == "" {
|
||
uploadKey, _ = data["uploadId"].(string)
|
||
}
|
||
if len(headers) == 0 {
|
||
headers = stringMapFromAny(data["headers"])
|
||
}
|
||
if headers == nil {
|
||
headers = map[string]string{}
|
||
}
|
||
if resourceURL == "" || uploadKey == "" {
|
||
err = apperrors.NewValidation(fmt.Sprintf("get_file_upload_info 返回不完整: resourceUrl=%q, uploadKey=%q", resourceURL, uploadKey))
|
||
return
|
||
}
|
||
return
|
||
}
|
||
|
||
func unwrapDocUploadResp(resp map[string]any) map[string]any {
|
||
if resp == nil {
|
||
return map[string]any{}
|
||
}
|
||
data := resp
|
||
for {
|
||
if content, ok := data["content"].(map[string]any); ok && len(content) > 0 {
|
||
data = content
|
||
continue
|
||
}
|
||
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
|
||
data = result
|
||
continue
|
||
}
|
||
if inner, ok := data["data"].(map[string]any); ok && len(inner) > 0 {
|
||
data = inner
|
||
continue
|
||
}
|
||
return data
|
||
}
|
||
}
|
||
|
||
func stringMapFromAny(raw any) map[string]string {
|
||
headers := map[string]string{}
|
||
if m, ok := raw.(map[string]any); ok {
|
||
for k, v := range m {
|
||
if s, ok := v.(string); ok && s != "" {
|
||
headers[k] = s
|
||
}
|
||
}
|
||
}
|
||
if m, ok := raw.(map[string]string); ok {
|
||
for k, v := range m {
|
||
if v != "" {
|
||
headers[k] = v
|
||
}
|
||
}
|
||
}
|
||
return headers
|
||
}
|
||
|
||
func newDocFileCreateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: i18n.T("创建文档文件"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "wiki node create")
|
||
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fileType, err := docRequiredFlag(cmd, "type")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"name": name, "type": fileType}
|
||
if folder := docFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
|
||
params["folderId"] = normalizeDocNodeID(folder)
|
||
}
|
||
if workspace := docFlagOrFallback(cmd, "workspace", "workspace-id"); workspace != "" {
|
||
params["workspaceId"] = workspace
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "create_file", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("name", "", i18n.T("文件名称 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "title", "--name alias")
|
||
cmd.Flags().String("type", "", i18n.T("文件类型 (必填)"))
|
||
cmd.Flags().String("folder", "", i18n.T("父文件夹 nodeId / URL"))
|
||
addDocHiddenStringFlag(cmd, "parent-id", "--folder alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("Workspace ID"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocFolderCreateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: i18n.T("创建文档文件夹"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "wiki node create --type folder")
|
||
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"name": name}
|
||
if folder := docFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
|
||
params["folderId"] = normalizeDocNodeID(folder)
|
||
}
|
||
if workspace := docFlagOrFallback(cmd, "workspace", "workspace-id"); workspace != "" {
|
||
params["workspaceId"] = workspace
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "create_folder", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("name", "", i18n.T("文件夹名称 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "title", "--name alias")
|
||
cmd.Flags().String("folder", "", i18n.T("父文件夹 nodeId / URL"))
|
||
addDocHiddenStringFlag(cmd, "parent-id", "--folder alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("Workspace ID"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocCopyCommand(runner executor.Runner) *cobra.Command {
|
||
return newDocTransferCommand(runner, "copy", "copy_document")
|
||
}
|
||
|
||
func newDocMoveCommand(runner executor.Runner) *cobra.Command {
|
||
return newDocTransferCommand(runner, "move", "move_document")
|
||
}
|
||
|
||
func newDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: use,
|
||
Short: i18n.T(use + " 文档节点"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive "+use)
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
if folder := docFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
|
||
params["targetFolderId"] = normalizeDocNodeID(folder)
|
||
}
|
||
if workspace := docFlagOrFallback(cmd, "workspace", "workspace-id"); workspace != "" {
|
||
params["workspaceId"] = workspace
|
||
}
|
||
return runDocTool(cmd, runner, "doc", tool, params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("folder", "", i18n.T("目标父文件夹 nodeId / URL"))
|
||
addDocHiddenStringFlag(cmd, "parent-id", "--folder alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("Workspace ID"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocRenameCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "rename",
|
||
Short: i18n.T("重命名文档节点"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive rename")
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
name, err := docRequiredFlagOrFallback(cmd, "name", "title")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "rename_document", map[string]any{
|
||
"nodeId": nodeID,
|
||
"newName": name,
|
||
})
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("name", "", i18n.T("新名称 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "title", "--name alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocBlockListCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: i18n.T("列出文档块"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
addDocIntParam(cmd, params, "startIndex", "start-index")
|
||
addDocIntParam(cmd, params, "endIndex", "end-index")
|
||
addDocStringParam(cmd, params, "blockType", "block-type")
|
||
format, err := docContentFormat(cmd, "element", "jsonml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if format != "" {
|
||
params["format"] = format
|
||
}
|
||
addDocStringParam(cmd, params, "blockId", "block-id")
|
||
return runDocTool(cmd, runner, "doc", "list_document_blocks", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().Int("start-index", 0, i18n.T("起始块索引"))
|
||
cmd.Flags().Int("end-index", 0, i18n.T("结束块索引"))
|
||
cmd.Flags().String("block-type", "", i18n.T("块类型"))
|
||
cmd.Flags().String("content-format", "", i18n.T("输出格式: element / jsonml"))
|
||
cmd.Flags().String("block-id", "", i18n.T("块 ID(JSONML 场景透传)"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocBlockInsertCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "insert",
|
||
Short: i18n.T("插入文档块"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
format, err := docContentFormat(cmd, "element", "jsonml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if format == "jsonml" {
|
||
jsonml, err := docRequiredFlag(cmd, "element")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
normalized, err := prepareDocJSONMLNode(cmd, jsonml)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params["format"] = "jsonml"
|
||
params["jsonml"] = normalized
|
||
if refBlock := docStringFlag(cmd, "ref-block"); refBlock != "" {
|
||
params["referenceBlockId"] = refBlock
|
||
where := normalizeDocInsertWhere(docStringFlag(cmd, "where"))
|
||
if where == "" {
|
||
where = "after"
|
||
}
|
||
params["where"] = where
|
||
}
|
||
if parentBlock := docStringFlag(cmd, "parent-block"); parentBlock != "" {
|
||
params["referenceBlockId"] = parentBlock
|
||
}
|
||
addDocIntParam(cmd, params, "index", "index")
|
||
} else {
|
||
if elementRaw := docStringFlag(cmd, "element"); elementRaw != "" && sniffJsonMLLike(elementRaw) {
|
||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: --element 内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 element 解析。`)
|
||
}
|
||
element, err := docBuildBlockElement(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params["element"] = element
|
||
addDocIntParam(cmd, params, "index", "index")
|
||
if where := normalizeDocInsertWhere(docStringFlag(cmd, "where")); where != "" {
|
||
params["where"] = where
|
||
}
|
||
addDocStringParam(cmd, params, "referenceBlockId", "ref-block")
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "insert_document_block", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("element", "", i18n.T("块 element JSON"))
|
||
cmd.Flags().String("text", "", i18n.T("段落文本快捷参数"))
|
||
cmd.Flags().String("heading", "", i18n.T("标题文本快捷参数"))
|
||
cmd.Flags().String("type", "", i18n.T("块类型快捷参数: paragraph/blockquote/callout/code/orderedList/unorderedList/columns/divider"))
|
||
cmd.Flags().String("list-id", "", i18n.T("列表 ID;orderedList/unorderedList 多项连续插入时使用同一个 ID"))
|
||
cmd.Flags().String("language", "", i18n.T("代码块语言;配合 --type code/codeBlock"))
|
||
cmd.Flags().Int("columns", 2, i18n.T("分栏数量;配合 --type columns"))
|
||
cmd.Flags().Int("level", 1, i18n.T("标题级别"))
|
||
cmd.Flags().Int("index", 0, i18n.T("插入索引"))
|
||
cmd.Flags().String("where", "", i18n.T("插入位置"))
|
||
cmd.Flags().String("ref-block", "", i18n.T("参考块 ID"))
|
||
cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml"))
|
||
cmd.Flags().String("parent-block", "", i18n.T("父容器块 ID(JSONML 场景透传)"))
|
||
addDocJSONMLControlFlags(cmd)
|
||
return cmd
|
||
}
|
||
|
||
func newDocBlockUpdateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: i18n.T("更新文档块"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
blockID, err := docRequiredFlag(cmd, "block-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{
|
||
"nodeId": nodeID,
|
||
"blockId": blockID,
|
||
}
|
||
format, err := docContentFormat(cmd, "element", "jsonml")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if format == "jsonml" {
|
||
jsonml, err := docRequiredFlag(cmd, "element")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
normalized, err := prepareDocJSONMLNode(cmd, jsonml)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params["format"] = "jsonml"
|
||
params["jsonml"] = normalized
|
||
} else {
|
||
if elementRaw := docStringFlag(cmd, "element"); elementRaw != "" && sniffJsonMLLike(elementRaw) {
|
||
fmt.Fprintln(cmd.ErrOrStderr(), `warning: --element 内容看起来是 JSONML 结构;若要按 JSONML 解析,请加 --content-format jsonml,否则将按 element 解析。`)
|
||
}
|
||
element, err := docBuildBlockElement(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params["element"] = element
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "update_document_block", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("block-id", "", i18n.T("块 ID (必填)"))
|
||
cmd.Flags().String("element", "", i18n.T("块 element JSON"))
|
||
cmd.Flags().String("text", "", i18n.T("段落文本快捷参数"))
|
||
cmd.Flags().String("heading", "", i18n.T("标题文本快捷参数"))
|
||
cmd.Flags().String("type", "", i18n.T("块类型快捷参数: paragraph/blockquote/callout/code/orderedList/unorderedList/columns/divider"))
|
||
cmd.Flags().String("list-id", "", i18n.T("列表 ID;orderedList/unorderedList 多项连续插入时使用同一个 ID"))
|
||
cmd.Flags().String("language", "", i18n.T("代码块语言;配合 --type code/codeBlock"))
|
||
cmd.Flags().Int("columns", 2, i18n.T("分栏数量;配合 --type columns"))
|
||
cmd.Flags().Int("level", 1, i18n.T("标题级别"))
|
||
cmd.Flags().String("content-format", "", i18n.T("输入格式: element / jsonml"))
|
||
addDocJSONMLControlFlags(cmd)
|
||
return cmd
|
||
}
|
||
|
||
func newDocBlockDeleteCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "delete",
|
||
Short: i18n.T("删除文档块"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
blockID, err := docRequiredFlag(cmd, "block-id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !confirmDeletePrompt(cmd, i18n.T("文档块"), blockID) {
|
||
return nil
|
||
}
|
||
return runDocTool(cmd, runner, "doc", "delete_document_block", map[string]any{
|
||
"nodeId": nodeID,
|
||
"blockId": blockID,
|
||
})
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("block-id", "", i18n.T("块 ID (必填)"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocCommentListCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: i18n.T("列出文档评论"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
if pageSize := docIntFlagOrFallback(cmd, "page-size", "limit"); pageSize > 0 {
|
||
params["pageSize"] = pageSize
|
||
}
|
||
addDocStringParam(cmd, params, "nextToken", "next-token", "cursor")
|
||
addDocStringParam(cmd, params, "commentType", "type")
|
||
addDocStringParam(cmd, params, "resolveStatus", "resolve-status")
|
||
return runDocTool(cmd, runner, "doc-comment", "list_comments", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().Int("page-size", 0, i18n.T("每页数量"))
|
||
cmd.Flags().Int("limit", 0, i18n.T("--page-size 的悟空兼容别名"))
|
||
_ = cmd.Flags().MarkHidden("limit")
|
||
cmd.Flags().String("next-token", "", i18n.T("下一页 token"))
|
||
addDocHiddenStringFlag(cmd, "cursor", "--next-token 的悟空兼容别名")
|
||
cmd.Flags().String("type", "", i18n.T("评论类型"))
|
||
cmd.Flags().String("resolve-status", "", i18n.T("解决状态"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocCommentCreateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "create",
|
||
Short: i18n.T("创建文档评论"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
content, err := docRequiredFlag(cmd, "content")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID, "content": content}
|
||
addDocCSVParam(cmd, params, "mentionedUserIds", "mention")
|
||
return runDocTool(cmd, runner, "doc-comment", "create_comment", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("content", "", i18n.T("评论内容 (必填)"))
|
||
cmd.Flags().String("mention", "", i18n.T("提及 userId,逗号分隔"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocCommentReplyCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "reply",
|
||
Short: i18n.T("回复文档评论"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
content, err := docRequiredFlag(cmd, "content")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID, "content": content}
|
||
addDocStringParam(cmd, params, "replyCommentKey", "comment-key")
|
||
if emoji, _ := cmd.Flags().GetBool("emoji"); emoji {
|
||
params["emoji"] = true
|
||
}
|
||
addDocCSVParam(cmd, params, "mentionedUserIds", "mention")
|
||
return runDocTool(cmd, runner, "doc-comment", "reply_comment", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("content", "", i18n.T("回复内容 (必填)"))
|
||
cmd.Flags().String("comment-key", "", i18n.T("评论 key"))
|
||
cmd.Flags().Bool("emoji", false, i18n.T("是否作为表情贴图回复"))
|
||
cmd.Flags().String("mention", "", i18n.T("提及 userId,逗号分隔"))
|
||
return cmd
|
||
}
|
||
|
||
func newDocCommentCreateInlineCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "create-inline",
|
||
Short: i18n.T("创建行内评论"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
content, err := docRequiredFlag(cmd, "content")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID, "content": content}
|
||
addDocStringParam(cmd, params, "blockId", "block-id")
|
||
addDocIntParam(cmd, params, "start", "start")
|
||
addDocIntParam(cmd, params, "end", "end")
|
||
addDocStringParam(cmd, params, "selectedText", "selected-text")
|
||
addDocCSVParam(cmd, params, "mentionedUserIds", "mention")
|
||
return runDocTool(cmd, runner, "doc-comment", "create_inline_comment", params)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("content", "", i18n.T("评论内容 (必填)"))
|
||
cmd.Flags().String("block-id", "", i18n.T("块 ID"))
|
||
cmd.Flags().Int("start", 0, i18n.T("起始位置"))
|
||
cmd.Flags().Int("end", 0, i18n.T("结束位置"))
|
||
cmd.Flags().String("selected-text", "", i18n.T("选中文本"))
|
||
cmd.Flags().String("mention", "", i18n.T("提及 userId,逗号分隔"))
|
||
return cmd
|
||
}
|
||
|
||
func docBuildBlockElement(cmd *cobra.Command) (any, error) {
|
||
if raw := docStringFlag(cmd, "element"); raw != "" {
|
||
var value any
|
||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("--element JSON parse failed: %v", err))
|
||
}
|
||
return value, nil
|
||
}
|
||
if heading := docStringFlag(cmd, "heading"); heading != "" {
|
||
level, _ := cmd.Flags().GetInt("level")
|
||
if level < 1 || level > 6 {
|
||
level = 1
|
||
}
|
||
return map[string]any{
|
||
"blockType": "heading",
|
||
"heading": map[string]any{
|
||
"text": heading,
|
||
"level": level,
|
||
},
|
||
}, nil
|
||
}
|
||
if blockType := docStringFlag(cmd, "type"); blockType != "" {
|
||
return docBuildTypedBlockElement(cmd, blockType)
|
||
}
|
||
if text := docStringFlag(cmd, "text"); text != "" {
|
||
return map[string]any{
|
||
"blockType": "paragraph",
|
||
"paragraph": map[string]any{
|
||
"text": text,
|
||
},
|
||
}, nil
|
||
}
|
||
return nil, apperrors.NewValidation("block content required: --text, --heading, or --element")
|
||
}
|
||
|
||
func docBuildTypedBlockElement(cmd *cobra.Command, blockType string) (any, error) {
|
||
text := docStringFlag(cmd, "text")
|
||
normalized := normalizeDocBlockType(blockType)
|
||
switch normalized {
|
||
case "paragraph":
|
||
if text == "" {
|
||
return nil, apperrors.NewValidation("--text is required when --type paragraph")
|
||
}
|
||
return map[string]any{
|
||
"blockType": "paragraph",
|
||
"paragraph": map[string]any{"text": text},
|
||
}, nil
|
||
case "heading":
|
||
if text == "" {
|
||
return nil, apperrors.NewValidation("--text is required when --type heading; or use --heading")
|
||
}
|
||
level, _ := cmd.Flags().GetInt("level")
|
||
if level < 1 || level > 6 {
|
||
level = 1
|
||
}
|
||
return map[string]any{
|
||
"blockType": "heading",
|
||
"heading": map[string]any{"text": text, "level": level},
|
||
}, nil
|
||
case "blockquote":
|
||
if text == "" {
|
||
return nil, apperrors.NewValidation("--text is required when --type blockquote")
|
||
}
|
||
return map[string]any{
|
||
"blockType": "blockquote",
|
||
"blockquote": map[string]any{"text": text},
|
||
}, nil
|
||
case "callout":
|
||
if text == "" {
|
||
return nil, apperrors.NewValidation("--text is required when --type callout")
|
||
}
|
||
return map[string]any{
|
||
"blockType": "callout",
|
||
"callout": map[string]any{"text": text},
|
||
}, nil
|
||
case "codeBlock":
|
||
if text == "" {
|
||
return nil, apperrors.NewValidation("--text is required when --type code")
|
||
}
|
||
codeBlock := map[string]any{"text": text}
|
||
if language := docStringFlag(cmd, "language"); language != "" {
|
||
codeBlock["language"] = language
|
||
}
|
||
return map[string]any{
|
||
"blockType": "codeBlock",
|
||
"codeBlock": codeBlock,
|
||
}, nil
|
||
case "orderedList", "unorderedList":
|
||
if text == "" {
|
||
return nil, apperrors.NewValidation("--text is required when --type orderedList/unorderedList")
|
||
}
|
||
listID := docStringFlag(cmd, "list-id")
|
||
if listID == "" {
|
||
listID = "list-1"
|
||
}
|
||
return map[string]any{
|
||
"blockType": normalized,
|
||
normalized: map[string]any{"list": map[string]any{"listId": listID}},
|
||
"children": []any{map[string]any{"text": text}},
|
||
}, nil
|
||
case "columns":
|
||
count, _ := cmd.Flags().GetInt("columns")
|
||
if count < 2 {
|
||
count = 2
|
||
}
|
||
parts := splitDocColumnsText(text, count)
|
||
children := make([]any, 0, count)
|
||
for i := 0; i < count; i++ {
|
||
childText := ""
|
||
if i < len(parts) {
|
||
childText = parts[i]
|
||
}
|
||
children = append(children, map[string]any{
|
||
"blockType": "paragraph",
|
||
"paragraph": map[string]any{"text": childText},
|
||
})
|
||
}
|
||
return map[string]any{
|
||
"blockType": "columns",
|
||
"columns": map[string]any{"size": count},
|
||
"children": children,
|
||
}, nil
|
||
case "divider":
|
||
return map[string]any{"blockType": "divider"}, nil
|
||
default:
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("unsupported --type %q; use --element for advanced block JSON", blockType))
|
||
}
|
||
}
|
||
|
||
func normalizeDocBlockType(blockType string) string {
|
||
switch strings.ToLower(strings.TrimSpace(blockType)) {
|
||
case "p", "text", "paragraph":
|
||
return "paragraph"
|
||
case "h", "heading", "title":
|
||
return "heading"
|
||
case "quote", "blockquote":
|
||
return "blockquote"
|
||
case "callout", "highlight", "notice":
|
||
return "callout"
|
||
case "code", "codeblock", "code_block", "code-block":
|
||
return "codeBlock"
|
||
case "orderedlist", "ordered-list", "ol", "numberedlist", "numbered-list":
|
||
return "orderedList"
|
||
case "unorderedlist", "unordered-list", "ul", "bulletlist", "bullet-list":
|
||
return "unorderedList"
|
||
case "columns", "column":
|
||
return "columns"
|
||
case "divider", "hr", "line":
|
||
return "divider"
|
||
default:
|
||
return strings.TrimSpace(blockType)
|
||
}
|
||
}
|
||
|
||
func splitDocColumnsText(text string, count int) []string {
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(text, "||")
|
||
if len(parts) == 1 {
|
||
parts = strings.Split(text, "|")
|
||
}
|
||
for i := range parts {
|
||
parts[i] = strings.TrimSpace(parts[i])
|
||
}
|
||
if len(parts) > count {
|
||
parts = parts[:count]
|
||
}
|
||
return parts
|
||
}
|
||
|
||
func normalizeDocInsertWhere(where string) string {
|
||
switch strings.ToLower(strings.TrimSpace(where)) {
|
||
case "", "end", "tail", "append", "末尾":
|
||
return ""
|
||
default:
|
||
return strings.TrimSpace(where)
|
||
}
|
||
}
|
||
|
||
func runDocTool(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
|
||
result, err := docInvocationResult(cmd, runner, product, tool, params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
}
|
||
|
||
func docInvocationResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
|
||
invocation := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), product, tool, params)
|
||
invocation.DryRun = commandDryRun(cmd)
|
||
return runner.Run(cmd.Context(), invocation)
|
||
}
|
||
|
||
func runDocReadJSONML(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
|
||
outputPath, _ := params["__output__"].(string)
|
||
result, err := docInvocationResult(cmd, runner, "doc", "get_document_content", params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !result.Invocation.Implemented {
|
||
return writeCommandPayload(cmd, result)
|
||
}
|
||
payload := normalizeDocReadJSONMLResult(result)
|
||
if outputPath != "" {
|
||
data, err := json.MarshalIndent(payload, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal JSONML output: %w", err)
|
||
}
|
||
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||
return fmt.Errorf("failed to write output file %s: %w", outputPath, err)
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "[INFO] JSONML 已写入 %s\n", outputPath)
|
||
return nil
|
||
}
|
||
return writeCommandPayload(cmd, payload)
|
||
}
|
||
|
||
func normalizeDocReadJSONMLResult(result executor.Result) map[string]any {
|
||
content := result.Response
|
||
if nested, ok := result.Response["content"].(map[string]any); ok {
|
||
content = nested
|
||
}
|
||
out := map[string]any{}
|
||
for k, v := range content {
|
||
if k == "content" {
|
||
continue
|
||
}
|
||
out[k] = v
|
||
}
|
||
if raw, ok := out["jsonml"].(string); ok {
|
||
var decoded any
|
||
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
|
||
out["jsonml"] = decoded
|
||
}
|
||
}
|
||
if revision, ok := out["version"]; ok {
|
||
if _, exists := out["revision"]; !exists {
|
||
out["revision"] = revision
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func addDocNodeFlags(cmd *cobra.Command) {
|
||
cmd.Flags().String("node", "", i18n.T("文档 nodeId / URL"))
|
||
addDocHiddenStringFlag(cmd, "url", "--node alias")
|
||
addDocHiddenStringFlag(cmd, "id", "--node alias")
|
||
addDocHiddenStringFlag(cmd, "node-id", "--node alias")
|
||
addDocHiddenStringFlag(cmd, "doc-id", "--node alias")
|
||
addDocHiddenStringFlag(cmd, "file-id", "--node alias")
|
||
}
|
||
|
||
func addDocJSONMLControlFlags(cmd *cobra.Command) {
|
||
cmd.Flags().Bool("fix-jsonml", false, i18n.T("启用全部 JSONML 修复(含 JSON 语法修复 + 结构修复),推荐 agent 调用时使用"))
|
||
cmd.Flags().Bool("no-fix-jsonml", false, i18n.T("关闭全部 JSONML 修复(跳过 JSON 语法修复和结构修复),用于排查原始错误"))
|
||
}
|
||
|
||
func addDocHiddenStringFlag(cmd *cobra.Command, name, usage string) {
|
||
cmd.Flags().String(name, "", usage)
|
||
_ = cmd.Flags().MarkHidden(name)
|
||
}
|
||
|
||
func docRequiredNode(cmd *cobra.Command) (string, error) {
|
||
nodeID, err := docRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return normalizeDocNodeID(nodeID), nil
|
||
}
|
||
|
||
func docResolveContent(cmd *cobra.Command) (string, error) {
|
||
if path := docFlagOrFallback(cmd, "content-file", "content-path"); path != "" {
|
||
return docReadContent(path)
|
||
}
|
||
raw := docFlagOrFallback(cmd, "content", "markdown")
|
||
if raw == "-" {
|
||
data, err := io.ReadAll(os.Stdin)
|
||
if err != nil {
|
||
return "", apperrors.NewValidation("read stdin: " + err.Error())
|
||
}
|
||
return string(data), nil
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
func docReadContent(path string) (string, error) {
|
||
if path == "-" {
|
||
data, err := io.ReadAll(os.Stdin)
|
||
if err != nil {
|
||
return "", apperrors.NewValidation("read stdin: " + err.Error())
|
||
}
|
||
return string(data), nil
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return "", apperrors.NewValidation("read " + path + ": " + err.Error())
|
||
}
|
||
return string(data), nil
|
||
}
|
||
|
||
func normalizeDocNodeID(raw string) string {
|
||
value := strings.TrimSpace(raw)
|
||
if value == "" || !strings.Contains(value, "://") {
|
||
return value
|
||
}
|
||
parts := strings.FieldsFunc(value, func(r rune) bool {
|
||
return r == '/' || r == '?' || r == '#'
|
||
})
|
||
for i, part := range parts {
|
||
if part == "nodes" && i+1 < len(parts) && strings.TrimSpace(parts[i+1]) != "" {
|
||
return strings.TrimSpace(parts[i+1])
|
||
}
|
||
}
|
||
return value
|
||
}
|
||
|
||
func docStringFlag(cmd *cobra.Command, name string) string {
|
||
if value, err := cmd.Flags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func docFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
|
||
if value := docStringFlag(cmd, primary); value != "" {
|
||
return value
|
||
}
|
||
for _, alias := range aliases {
|
||
if value := docStringFlag(cmd, alias); value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func docContentFormat(cmd *cobra.Command, allowed ...string) (string, error) {
|
||
format := docStringFlag(cmd, "content-format")
|
||
if format == "" {
|
||
return "", nil
|
||
}
|
||
for _, candidate := range allowed {
|
||
if format == candidate {
|
||
return format, nil
|
||
}
|
||
}
|
||
if format == "json" {
|
||
return "", apperrors.NewValidation(fmt.Sprintf("--content-format json 无效。这里的 --content-format 指文档内容格式,不是 CLI 输出格式;CLI 输出格式由顶层 -f/--format 控制。本命令接受: %s", renderDocAllowedFormats(allowed)))
|
||
}
|
||
return "", apperrors.NewValidation(fmt.Sprintf("--content-format 取值 %q 无效,仅接受: %s", format, renderDocAllowedFormats(allowed)))
|
||
}
|
||
|
||
func renderDocAllowedFormats(allowed []string) string {
|
||
parts := make([]string, 0, len(allowed))
|
||
for _, allowedFormat := range allowed {
|
||
if allowedFormat != "" {
|
||
parts = append(parts, allowedFormat)
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
func docFirstStringRecursive(v any, keys ...string) string {
|
||
keySet := make(map[string]struct{}, len(keys))
|
||
for _, key := range keys {
|
||
keySet[key] = struct{}{}
|
||
}
|
||
var walk func(any) string
|
||
walk = func(cur any) string {
|
||
switch x := cur.(type) {
|
||
case map[string]any:
|
||
for _, key := range keys {
|
||
if value, ok := x[key].(string); ok && strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
for key, value := range x {
|
||
if _, direct := keySet[key]; direct {
|
||
continue
|
||
}
|
||
if found := walk(value); found != "" {
|
||
return found
|
||
}
|
||
}
|
||
case []any:
|
||
for _, item := range x {
|
||
if found := walk(item); found != "" {
|
||
return found
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
return walk(v)
|
||
}
|
||
|
||
func docRequiredFlag(cmd *cobra.Command, name string) (string, error) {
|
||
if value := docStringFlag(cmd, name); value != "" {
|
||
return value, nil
|
||
}
|
||
return "", apperrors.NewValidation("--" + name + " is required")
|
||
}
|
||
|
||
func docRequiredFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
|
||
if value := docFlagOrFallback(cmd, primary, aliases...); value != "" {
|
||
return value, nil
|
||
}
|
||
return "", apperrors.NewValidation("--" + primary + " is required")
|
||
}
|
||
|
||
func docIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) int {
|
||
names := append([]string{primary}, aliases...)
|
||
for _, name := range names {
|
||
flag := cmd.Flags().Lookup(name)
|
||
if flag == nil || !flag.Changed {
|
||
continue
|
||
}
|
||
value, err := cmd.Flags().GetInt(name)
|
||
if err == nil && value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
for _, name := range names {
|
||
value, err := cmd.Flags().GetInt(name)
|
||
if err == nil && value > 0 {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func docModeRequiresYes(mode string) bool {
|
||
return strings.EqualFold(strings.TrimSpace(mode), "overwrite")
|
||
}
|
||
|
||
func docYesFlag(cmd *cobra.Command) bool {
|
||
if cmd == nil {
|
||
return false
|
||
}
|
||
if value, err := cmd.Flags().GetBool("yes"); err == nil {
|
||
return value
|
||
}
|
||
if value, err := cmd.InheritedFlags().GetBool("yes"); err == nil {
|
||
return value
|
||
}
|
||
if root := cmd.Root(); root != nil {
|
||
if value, err := root.PersistentFlags().GetBool("yes"); err == nil {
|
||
return value
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func addDocStringParam(cmd *cobra.Command, params map[string]any, paramName string, flags ...string) {
|
||
for _, flag := range flags {
|
||
if value := docStringFlag(cmd, flag); value != "" {
|
||
params[paramName] = value
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func addDocCSVParam(cmd *cobra.Command, params map[string]any, paramName string, flags ...string) {
|
||
for _, flag := range flags {
|
||
if value := docStringFlag(cmd, flag); value != "" {
|
||
params[paramName] = docCSV(value)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func addDocIntParam(cmd *cobra.Command, params map[string]any, paramName string, flags ...string) {
|
||
for _, flag := range flags {
|
||
if !cmd.Flags().Changed(flag) {
|
||
continue
|
||
}
|
||
if value, err := cmd.Flags().GetInt(flag); err == nil {
|
||
params[paramName] = value
|
||
return
|
||
}
|
||
if value, err := cmd.Flags().GetInt64(flag); err == nil {
|
||
params[paramName] = value
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func docCSV(raw string) []string {
|
||
parts := strings.Split(raw, ",")
|
||
values := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if value := strings.TrimSpace(part); value != "" {
|
||
values = append(values, value)
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
// TRANSITIONAL: 等 mse 把 submit_export_job / query_export_job 加入
|
||
// doc toolOverrides 后,本节 helper 可整体删除。工单:plan/mse-yuyuan-patch.md
|
||
// 改动 2.2。
|
||
//
|
||
// 设计:用 pkg/asynctask.Submit 串起来:
|
||
// 1. 调 submit_export_job 拿 jobId
|
||
// 2. 渐进式退避轮询 query_export_job 直到 SUCCESS / FAILED / 超时
|
||
// 3. SUCCESS 后自动 GET downloadUrl 落盘到必填的 --output 路径。
|
||
|
||
func runDocExport(cmd *cobra.Command, runner executor.Runner) error {
|
||
nodeID := docFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||
if strings.TrimSpace(nodeID) == "" {
|
||
// 没传 --node 时按 group 命令处理(打 help)
|
||
if !cmd.Flags().Changed("node") && !cmd.Flags().Changed("output") {
|
||
return cmd.Help()
|
||
}
|
||
return apperrors.NewValidation("--node is required")
|
||
}
|
||
nodeID = normalizeDocNodeID(nodeID)
|
||
output, _ := cmd.Flags().GetString("output")
|
||
if strings.TrimSpace(output) == "" {
|
||
return apperrors.NewValidation("--output is required")
|
||
}
|
||
timeoutSec, _ := cmd.Flags().GetInt("timeout-sec")
|
||
progressOut := cmd.OutOrStdout()
|
||
emitProgress := func(format string, args ...any) {
|
||
msg := fmt.Sprintf(i18n.T(format), args...)
|
||
fmt.Fprint(progressOut, msg)
|
||
}
|
||
exportFormat := strings.TrimSpace(docStringFlag(cmd, "export-format"))
|
||
if exportFormat != "" && !strings.EqualFold(exportFormat, "docx") {
|
||
return apperrors.NewValidation("--export-format only supports docx")
|
||
}
|
||
|
||
submitFn := func(ctx context.Context) (string, error) {
|
||
params := map[string]any{"nodeId": nodeID}
|
||
if exportFormat != "" {
|
||
params["exportFormat"] = exportFormat
|
||
}
|
||
result, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "submit_export_job", params,
|
||
))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
jobID := extractDocExportJobID(result.Response)
|
||
if jobID != "" {
|
||
emitProgress(" 任务已提交,jobId: %s\n", jobID)
|
||
emitProgress("[2/3] 等待导出完成...\n")
|
||
}
|
||
return jobID, nil
|
||
}
|
||
|
||
queryFn := func(ctx context.Context, jobID string) (asynctask.QueryResult, error) {
|
||
result, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "query_export_job",
|
||
map[string]any{"jobId": jobID},
|
||
))
|
||
if err != nil {
|
||
return asynctask.QueryResult{}, err
|
||
}
|
||
return parseDocExportQueryResult(result.Response), nil
|
||
}
|
||
|
||
if commandDryRun(cmd) {
|
||
params := map[string]any{"nodeId": nodeID, "__async__": true, "__output__": output}
|
||
if exportFormat != "" {
|
||
params["exportFormat"] = exportFormat
|
||
}
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "submit_export_job",
|
||
params,
|
||
))
|
||
}
|
||
|
||
emitProgress("[1/3] 提交导出任务...\n")
|
||
res, err := asynctask.Submit(cmd.Context(), submitFn, queryFn, asynctask.Options{
|
||
Timeout: time.Duration(timeoutSec) * time.Second,
|
||
ProgressFn: func(attempt int, status asynctask.Status, elapsed time.Duration) {
|
||
emitProgress(" 第 %d/30 次查询(状态=%s,已耗时 %s)\n",
|
||
attempt, status, elapsed.Round(time.Second))
|
||
},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
out := map[string]any{
|
||
"jobId": res.JobID,
|
||
"status": string(res.Status),
|
||
}
|
||
if res.DownloadURL != "" {
|
||
out["downloadUrl"] = res.DownloadURL
|
||
}
|
||
if res.Message != "" {
|
||
out["message"] = res.Message
|
||
}
|
||
|
||
switch res.Status {
|
||
case asynctask.StatusSuccess:
|
||
if res.DownloadURL != "" {
|
||
outputPath := resolveDocExportOutputPath(output, res.DownloadURL, res.JobID)
|
||
emitProgress("[3/3] 下载文件到 %s ...\n", outputPath)
|
||
if err := asynctask.Download(cmd.Context(), res.DownloadURL, outputPath); err != nil {
|
||
return fmt.Errorf(i18n.T("download failed: %w"), err)
|
||
}
|
||
out["output"] = outputPath
|
||
emitProgress("导出完成: %s\n", outputPath)
|
||
}
|
||
case asynctask.StatusFailed:
|
||
return apperrors.NewValidation(fmt.Sprintf("export failed: %s", res.Message))
|
||
case asynctask.StatusTimeout:
|
||
emitProgress("任务超时,请用 dws doc export get --job-id %s 继续等待\n", res.JobID)
|
||
}
|
||
|
||
return writeCommandPayload(cmd, out)
|
||
}
|
||
|
||
func resolveDocExportOutputPath(outputPath, downloadURL, jobID string) string {
|
||
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
|
||
return filepath.Join(outputPath, docExportFilename(downloadURL, jobID))
|
||
}
|
||
return outputPath
|
||
}
|
||
|
||
func docExportFilename(downloadURL, jobID string) string {
|
||
if parsed, err := url.Parse(downloadURL); err == nil {
|
||
name := cleanDocExportFilename(filepath.Base(parsed.Path))
|
||
if name != "" {
|
||
if decoded, decodeErr := url.PathUnescape(name); decodeErr == nil {
|
||
if decodedName := cleanDocExportFilename(decoded); decodedName != "" {
|
||
return decodedName
|
||
}
|
||
}
|
||
return name
|
||
}
|
||
}
|
||
if jobID != "" {
|
||
return "doc_export_" + jobID + ".docx"
|
||
}
|
||
return "doc_export.docx"
|
||
}
|
||
|
||
func cleanDocExportFilename(name string) string {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return ""
|
||
}
|
||
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||
if name == "" || name == "." || name == "/" {
|
||
return ""
|
||
}
|
||
return name
|
||
}
|
||
|
||
func newDocExportGetCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "get",
|
||
Short: i18n.T("查询导出任务结果(兜底,dws doc export 已自动轮询)"),
|
||
Long: i18n.T(`根据 jobId 查询文档导出任务的执行结果。
|
||
|
||
通常不需要手动调用 —— dws doc export 已内置自动轮询。
|
||
仅在导出命令超时或中断后,用于手动查询任务状态/续等。
|
||
|
||
任务状态:
|
||
PROCESSING 处理中
|
||
SUCCESS 导出成功,返回 downloadUrl
|
||
FAILED 导出失败`),
|
||
Example: " dws doc export get --job-id <JOB_ID>",
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
jobID, _ := cmd.Flags().GetString("job-id")
|
||
if strings.TrimSpace(jobID) == "" {
|
||
return apperrors.NewValidation("--job-id is required")
|
||
}
|
||
if !docExportJobIDLooksValid(jobID) {
|
||
return apperrors.NewValidation("--job-id must be a numeric export job ID")
|
||
}
|
||
output, _ := cmd.Flags().GetString("output")
|
||
timeoutSec, _ := cmd.Flags().GetInt("timeout-sec")
|
||
|
||
queryFn := func(ctx context.Context, jobID string) (asynctask.QueryResult, error) {
|
||
result, err := runner.Run(ctx, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "query_export_job",
|
||
map[string]any{"jobId": jobID},
|
||
))
|
||
if err != nil {
|
||
return asynctask.QueryResult{}, err
|
||
}
|
||
return parseDocExportQueryResult(result.Response), nil
|
||
}
|
||
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "query_export_job",
|
||
map[string]any{"jobId": jobID},
|
||
))
|
||
}
|
||
|
||
res, err := asynctask.Resume(cmd.Context(), jobID, queryFn, asynctask.Options{
|
||
Timeout: time.Duration(timeoutSec) * time.Second,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
out := map[string]any{
|
||
"jobId": res.JobID,
|
||
"status": string(res.Status),
|
||
}
|
||
if res.DownloadURL != "" {
|
||
out["downloadUrl"] = res.DownloadURL
|
||
}
|
||
if res.Message != "" {
|
||
out["message"] = res.Message
|
||
}
|
||
if res.Status == asynctask.StatusSuccess && output != "" && res.DownloadURL != "" {
|
||
outputPath := resolveDocExportOutputPath(output, res.DownloadURL, res.JobID)
|
||
if err := asynctask.Download(cmd.Context(), res.DownloadURL, outputPath); err != nil {
|
||
return fmt.Errorf(i18n.T("download failed: %w"), err)
|
||
}
|
||
out["output"] = outputPath
|
||
}
|
||
if res.Status == asynctask.StatusFailed {
|
||
return apperrors.NewValidation(fmt.Sprintf("export failed: %s", res.Message))
|
||
}
|
||
return writeCommandPayload(cmd, out)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
cmd.Flags().String("job-id", "", i18n.T("导出任务 ID (必填)"))
|
||
cmd.Flags().String("output", "", i18n.T("本地落盘路径(可选,提供则自动下载 docx 到本地)"))
|
||
cmd.Flags().Int("timeout-sec", 300, i18n.T("整体轮询超时(秒),默认 300"))
|
||
return cmd
|
||
}
|
||
|
||
func docExportJobIDLooksValid(jobID string) bool {
|
||
jobID = strings.TrimSpace(jobID)
|
||
if jobID == "" {
|
||
return false
|
||
}
|
||
for _, r := range jobID {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// extractDocExportJobID 从 submit_export_job 响应里抽 jobId。
|
||
func extractDocExportJobID(resp map[string]any) string {
|
||
src := unwrapDocResp(resp)
|
||
if id, ok := src["jobId"].(string); ok && id != "" {
|
||
return id
|
||
}
|
||
if id, ok := src["taskId"].(string); ok && id != "" {
|
||
return id
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// parseDocExportQueryResult 把 query_export_job 的响应转成 asynctask.QueryResult。
|
||
// 注意:必须复用 normalizeAsyncStatus(来自 aitable_export_import.go),否则服务端
|
||
// 返回 SUCCEED / DONE / FINISHED / FAILURE 等变体时,asynctask.Resume 会走 default
|
||
// 分支当成 PROCESSING 继续等到超时。
|
||
func parseDocExportQueryResult(resp map[string]any) asynctask.QueryResult {
|
||
src := unwrapDocResp(resp)
|
||
statusRaw, _ := src["status"].(string)
|
||
msg, _ := src["message"].(string)
|
||
url, _ := src["downloadUrl"].(string)
|
||
return asynctask.QueryResult{
|
||
Status: normalizeAsyncStatus(statusRaw, url != ""),
|
||
DownloadURL: url,
|
||
Message: msg,
|
||
Raw: src,
|
||
}
|
||
}
|
||
|
||
// unwrapDocResp 处理两种包装层次:result.Response 直接含字段 / 含 content.data。
|
||
func unwrapDocResp(resp map[string]any) map[string]any {
|
||
if resp == nil {
|
||
return map[string]any{}
|
||
}
|
||
if content, ok := resp["content"].(map[string]any); ok && len(content) > 0 {
|
||
resp = content
|
||
}
|
||
if data, ok := resp["data"].(map[string]any); ok && len(data) > 0 {
|
||
return data
|
||
}
|
||
return resp
|
||
}
|
||
|
||
// TRANSITIONAL: 等 mse 把 add_permission / update_permission /
|
||
// list_permission 加入 doc toolOverrides(group: "permission")后,
|
||
// 本节 3 个 helper 可整体删除。工单:plan/mse-yuyuan-patch.md 改动 2.2。
|
||
//
|
||
// 与 wiki member add 的关键区别:
|
||
// - wiki member add —— 知识库(workspace)容器级授权
|
||
// - doc permission —— 节点(document/file/folder)级授权
|
||
|
||
var docPermissionRoles = map[string]bool{
|
||
"MANAGER": true,
|
||
"EDITOR": true,
|
||
"DOWNLOADER": true,
|
||
"READER": true,
|
||
}
|
||
|
||
// normalizeDocPermissionRole 把用户输入的 role 转为悟空兼容的大写形式。
|
||
// 返回 (规范化后的 role, 是否合法)。OWNER 不允许通过本接口设置。
|
||
func normalizeDocPermissionRole(raw string) (string, bool) {
|
||
r := strings.ToUpper(strings.TrimSpace(raw))
|
||
if r == "" {
|
||
return "", false
|
||
}
|
||
if r == "OWNER" {
|
||
// OWNER 不可通过 add/update 接口添加,统一拒绝
|
||
return r, false
|
||
}
|
||
return r, docPermissionRoles[r]
|
||
}
|
||
|
||
// parseDocPermissionUsers 把逗号分隔的 userId 列表拆成数组,单次最多 30 个。
|
||
func parseDocPermissionUsers(raw string) ([]string, error) {
|
||
parts := strings.Split(raw, ",")
|
||
users := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p != "" {
|
||
users = append(users, p)
|
||
}
|
||
}
|
||
if len(users) == 0 {
|
||
return nil, apperrors.NewValidation("--user must contain at least 1 userId")
|
||
}
|
||
if len(users) > 30 {
|
||
return nil, apperrors.NewValidation(fmt.Sprintf("--user supports at most 30 ids per call (got %d)", len(users)))
|
||
}
|
||
return users, nil
|
||
}
|
||
|
||
func newDocPermissionAddCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "add",
|
||
Short: i18n.T("添加文档协作者"),
|
||
Long: i18n.T(`为指定节点(文档/文件夹/文件)添加一个或多个协作成员,并授予指定角色。
|
||
|
||
支持角色(--role 大小写不敏感,内部规范化为大写):
|
||
MANAGER 管理员,可读写、管理成员
|
||
EDITOR 编辑者,可查看、编辑、上传内容
|
||
DOWNLOADER 查看下载者,可查看并下载内容
|
||
READER 仅可查看者
|
||
|
||
注意:
|
||
- OWNER 角色不可通过本命令添加
|
||
- 单次 --user 最多 30 个 id;超过请分批调用
|
||
- 本命令是节点级授权,跟 dws wiki member add(容器级授权)不同`),
|
||
Example: ` dws doc permission add --node DOC_ID --user uid1,uid2 --role READER
|
||
dws doc permission add --node DOC_ID --user uid1 --role MANAGER`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive permission add")
|
||
return runDocPermissionMutation(cmd, runner, "add_permission")
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("user", "", i18n.T("被授权用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "users", "--user alias")
|
||
cmd.Flags().String("role", "", i18n.T("权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)"))
|
||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填,辅助构造返回的 docUrl)"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocPermissionUpdateCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "update",
|
||
Short: i18n.T("更新文档协作者权限"),
|
||
Long: i18n.T(`更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
|
||
|
||
支持角色与限制同 dws doc permission add。
|
||
|
||
仅可更新已存在协作关系的用户;新增协作者请使用 add。`),
|
||
Example: ` dws doc permission update --node DOC_ID --user uid1 --role EDITOR
|
||
dws doc permission update --node DOC_ID --user uid1,uid2 --role READER`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive permission update")
|
||
return runDocPermissionMutation(cmd, runner, "update_permission")
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("user", "", i18n.T("被更新用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "users", "--user alias")
|
||
addDocHiddenStringFlag(cmd, "uid", "--user alias")
|
||
cmd.Flags().String("role", "", i18n.T("新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)"))
|
||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocPermissionListCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "list",
|
||
Short: i18n.T("查询文档协作者列表"),
|
||
Long: i18n.T(`查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
|
||
|
||
底层不支持游标分页;--max-results 仅控制单次返回最大条数(默认 30,最大 200)。
|
||
若 truncated=true,可通过 --filter-role 收窄查询。`),
|
||
Example: ` dws doc permission list --node DOC_ID
|
||
dws doc permission list --node DOC_ID --max-results 100
|
||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive permission list")
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
if v := docIntFlagOrFallback(cmd, "max-results", "limit", "page-size"); v > 0 {
|
||
if v < 1 || v > 200 {
|
||
return apperrors.NewValidation("--max-results/--limit/--page-size must be between 1 and 200")
|
||
}
|
||
params["maxResults"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("filter-role"); v != "" {
|
||
roles := make([]string, 0)
|
||
for _, r := range strings.Split(v, ",") {
|
||
norm, ok := normalizeDocPermissionRole(r)
|
||
if !ok && strings.ToUpper(strings.TrimSpace(r)) != "OWNER" {
|
||
return apperrors.NewValidation(fmt.Sprintf("--filter-role got invalid role: %s", r))
|
||
}
|
||
roles = append(roles, norm)
|
||
}
|
||
params["filterRoleIds"] = roles
|
||
}
|
||
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
params["workspaceId"] = v
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "list_permission", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "list_permission", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().Int("max-results", 30, i18n.T("返回成员数上限,默认 30,最大 200"))
|
||
cmd.Flags().Int("limit", 0, i18n.T("--max-results 的兼容别名"))
|
||
cmd.Flags().Int("page-size", 0, i18n.T("--max-results 的兼容别名"))
|
||
_ = cmd.Flags().MarkHidden("limit")
|
||
_ = cmd.Flags().MarkHidden("page-size")
|
||
cmd.Flags().String("filter-role", "", i18n.T("按角色过滤(逗号分隔): OWNER / MANAGER / EDITOR / DOWNLOADER / READER"))
|
||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
func newDocPermissionRemoveCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "remove",
|
||
Aliases: []string{"rm"},
|
||
Short: i18n.T("移除文档协作者权限"),
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
docDeprecatedNotice(cmd, "drive permission remove")
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rawUsers := docFlagOrFallback(cmd, "users", "user", "uid")
|
||
if strings.TrimSpace(rawUsers) == "" {
|
||
return apperrors.NewValidation("--users is required")
|
||
}
|
||
userIDs, err := parseDocPermissionUsers(rawUsers)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
params := map[string]any{
|
||
"nodeId": nodeID,
|
||
"userIds": userIDs,
|
||
}
|
||
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
params["workspaceId"] = v
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "remove_permission", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("users", "", i18n.T("被移除用户 userId 列表,逗号分隔,单次最多 30 (必填)"))
|
||
addDocHiddenStringFlag(cmd, "user", "--users alias")
|
||
addDocHiddenStringFlag(cmd, "uid", "--users alias")
|
||
cmd.Flags().String("workspace", "", i18n.T("目标知识库 ID 或 URL(选填)"))
|
||
addDocHiddenStringFlag(cmd, "workspace-id", "--workspace alias")
|
||
return cmd
|
||
}
|
||
|
||
// runDocPermissionMutation 是 add / update 两个命令共用的执行体:
|
||
// 校验 → 规范化 → 调对应 MCP tool。
|
||
func runDocPermissionMutation(cmd *cobra.Command, runner executor.Runner, mcpTool string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rawUsers := docFlagOrFallback(cmd, "user", "users", "uid")
|
||
if strings.TrimSpace(rawUsers) == "" {
|
||
return apperrors.NewValidation("--user is required")
|
||
}
|
||
userIDs, err := parseDocPermissionUsers(rawUsers)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rawRole, _ := cmd.Flags().GetString("role")
|
||
if strings.TrimSpace(rawRole) == "" {
|
||
return apperrors.NewValidation("--role is required")
|
||
}
|
||
role, ok := normalizeDocPermissionRole(rawRole)
|
||
if !ok {
|
||
if role == "OWNER" {
|
||
return apperrors.NewValidation("OWNER role cannot be set via permission add/update")
|
||
}
|
||
return apperrors.NewValidation(fmt.Sprintf("invalid --role: %s (expected MANAGER / EDITOR / DOWNLOADER / READER)", rawRole))
|
||
}
|
||
params := map[string]any{
|
||
"nodeId": nodeID,
|
||
"roleId": role,
|
||
"userIds": userIDs,
|
||
}
|
||
if v := docFlagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||
params["workspaceId"] = v
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", mcpTool, params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", mcpTool, params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
}
|
||
|
||
func docDeprecatedNotice(cmd *cobra.Command, replacement string) {
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "warning: deprecated: use dws %s instead.\n", replacement)
|
||
}
|
||
|
||
// TRANSITIONAL: 等 mse 把 delete_document 加入 doc toolOverrides(含
|
||
// destructive_hint: true)后,本 helper 可删除——CLI discovery 会自动
|
||
// 生成等价命令。工单:plan/mse-yuyuan-patch.md 改动 2.2。
|
||
//
|
||
// 命名注意:必须挂在 doc 顶层(不在 block group 下),与现有 mse 中
|
||
// `dws doc block delete`(删块,调 delete_document_block)做区分。
|
||
func newDocDeleteCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "delete",
|
||
Short: i18n.T("删除整篇文档/文件到回收站"),
|
||
Long: i18n.T(`将文档或文件移入回收站(高风险、不可逆操作)。
|
||
|
||
权限要求:对目标节点有"管理"权限。
|
||
执行前需要确认(交互式输入 yes),或传入 --yes 跳过确认。
|
||
|
||
与 dws doc block delete 的区别:
|
||
- dws doc delete —— 删除整篇文档/文件(本命令)
|
||
- dws doc block delete —— 删除文档内部的某个块`),
|
||
Example: ` dws doc delete --node DOC_ID --yes
|
||
dws doc delete --node "https://alidocs.dingtalk.com/i/nodes/<UUID>" --yes
|
||
dws doc delete --node DOC_ID # 交互式确认后删除`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !confirmDeletePrompt(cmd, i18n.T("文档节点"), nodeID) {
|
||
return nil
|
||
}
|
||
params := map[string]any{"nodeId": nodeID}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "delete_document", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "delete_document", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
return cmd
|
||
}
|
||
|
||
// TRANSITIONAL: 等 mse 把 download_doc_attachment 加入 doc toolOverrides
|
||
// (cliName: "download", group: "media") 后,本 helper 可删除。
|
||
// 工单:plan/mse-yuyuan-patch.md 改动 2.2。
|
||
//
|
||
// 单 MCP tool 包装,无本地文件 IO(只拿临时下载 URL,由调用方自行 GET)。
|
||
func newDocMediaDownloadCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "download",
|
||
Short: i18n.T("获取文档附件的临时下载链接"),
|
||
Long: i18n.T(`获取钉钉文档中指定附件的 OSS 临时下载链接,返回 downloadUrl 和过期时间。
|
||
|
||
resource-id 可通过 dws doc block list 获取:查询目标文档的块列表,找到
|
||
blockType 为 attachment 的元素,取其 resourceId。
|
||
|
||
本命令不下载文件到本地,仅返回 URL。如需落盘,调用方自行 GET 该 URL。`),
|
||
Example: ` dws doc media download --node DOC_ID --resource-id RESOURCE_ID
|
||
dws doc media download --node "https://alidocs.dingtalk.com/i/nodes/<UUID>" --resource-id <ID>`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resourceID, _ := cmd.Flags().GetString("resource-id")
|
||
if strings.TrimSpace(resourceID) == "" {
|
||
return apperrors.NewValidation("--resource-id is required")
|
||
}
|
||
params := map[string]any{
|
||
"nodeId": nodeID,
|
||
"resourceId": resourceID,
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "download_doc_attachment", params,
|
||
))
|
||
}
|
||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "download_doc_attachment", params,
|
||
))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return writeCommandPayload(cmd, result)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("resource-id", "", i18n.T("附件 resourceId,可通过 doc block list 获取 (必填)"))
|
||
return cmd
|
||
}
|
||
|
||
// newDocMediaInsertCommand 把本地文件作为附件上传并插入文档,三步合一:
|
||
// 1. get_doc_attachment_upload_info → 获取 uploadUrl + resourceId
|
||
// 2. HTTP PUT 文件到 OSS
|
||
// 3. insert_document_block → 把附件块挂到文档
|
||
//
|
||
// 必须 helper 实现:第 2 步 HTTP PUT 是客户端文件 IO,无法用 mse toolOverrides 表达。
|
||
func newDocMediaInsertCommand(runner executor.Runner) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "insert",
|
||
Short: i18n.T("上传本地文件并作为附件插入文档(3 步合一:prepare + PUT + insert)"),
|
||
Long: i18n.T(`将本地文件作为附件上传并插入到钉钉文档中(三步自动完成)。
|
||
|
||
流程:
|
||
1. 获取附件上传凭证 (get_doc_attachment_upload_info)
|
||
2. HTTP PUT 上传文件到 OSS
|
||
3. 插入附件块到文档 (insert_document_block)
|
||
|
||
图片文件(image/*)小于 20MB 时会作为内联图片插入;其他文件作为附件块插入。
|
||
--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`,
|
||
Args: cobra.NoArgs,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return runDocMediaInsert(cmd, runner)
|
||
},
|
||
}
|
||
preferLegacyLeaf(cmd)
|
||
addDocNodeFlags(cmd)
|
||
cmd.Flags().String("file", "", i18n.T("本地文件路径 (必填)"))
|
||
cmd.Flags().String("name", "", i18n.T("附件显示名称(默认使用文件名)"))
|
||
cmd.Flags().String("mime-type", "", i18n.T("文件 MIME 类型(默认根据扩展名推断)"))
|
||
addDocHiddenStringFlag(cmd, "mimetype", "--mime-type alias")
|
||
cmd.Flags().Int("index", 0, i18n.T("插入位置索引"))
|
||
cmd.Flags().String("where", "", i18n.T("相对位置: before / after(配合 --ref-block)"))
|
||
cmd.Flags().String("ref-block", "", i18n.T("参考块 ID(配合 --where)"))
|
||
return cmd
|
||
}
|
||
|
||
const docMaxInlineImageSize = 20 * 1024 * 1024 // 20MB
|
||
|
||
func runDocMediaInsert(cmd *cobra.Command, runner executor.Runner) error {
|
||
nodeID, err := docRequiredNode(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
filePath, _ := cmd.Flags().GetString("file")
|
||
if strings.TrimSpace(filePath) == "" {
|
||
return apperrors.NewValidation("--file is required")
|
||
}
|
||
|
||
absPath, err := filepath.Abs(filePath)
|
||
if err != nil {
|
||
return apperrors.NewValidation(i18n.T("无法解析文件路径: ") + err.Error())
|
||
}
|
||
info, err := os.Stat(absPath)
|
||
if err != nil {
|
||
return apperrors.NewValidation(i18n.T("文件不存在: ") + absPath)
|
||
}
|
||
if info.IsDir() {
|
||
return apperrors.NewValidation(i18n.T("不是文件: ") + absPath)
|
||
}
|
||
fileSize := info.Size()
|
||
if fileSize <= 0 {
|
||
return apperrors.NewValidation(i18n.T("文件为空"))
|
||
}
|
||
if fileSize > config.MaxUploadFileSize {
|
||
return apperrors.NewValidation(fmt.Sprintf(i18n.T("文件过大 (%d 字节,限制 %d 字节)"), fileSize, config.MaxUploadFileSize))
|
||
}
|
||
|
||
fileName, _ := cmd.Flags().GetString("name")
|
||
if fileName == "" {
|
||
fileName = filepath.Base(absPath)
|
||
} else if filepath.Ext(fileName) == "" {
|
||
if ext := filepath.Ext(absPath); ext != "" {
|
||
fileName += ext
|
||
}
|
||
}
|
||
|
||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||
if mimeType == "" {
|
||
mimeType = detectMIME(fileName)
|
||
}
|
||
|
||
// Step 1: 获取上传凭证
|
||
fmt.Fprintf(os.Stderr, i18n.T("步骤 1/3: 获取附件上传凭证 (%s, %d 字节)...\n"), fileName, fileSize)
|
||
step1Params := map[string]any{
|
||
"nodeId": nodeID,
|
||
"fileName": fileName,
|
||
"fileSize": float64(fileSize),
|
||
"mimeType": mimeType,
|
||
}
|
||
if commandDryRun(cmd) {
|
||
return writeCommandPayload(cmd, executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "get_doc_attachment_upload_info", step1Params,
|
||
))
|
||
}
|
||
credResult, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "get_doc_attachment_upload_info", step1Params,
|
||
))
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("获取上传凭证失败: %w"), err)
|
||
}
|
||
|
||
uploadURL, resourceID, resourceURL, err := extractDocAttachmentUploadInfo(credResult.Response)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Step 2: HTTP PUT
|
||
fmt.Fprintln(os.Stderr, i18n.T("步骤 2/3: 上传文件到 OSS..."))
|
||
f, err := os.Open(absPath)
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("无法打开文件: %w"), err)
|
||
}
|
||
defer f.Close()
|
||
|
||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodPut, uploadURL, f)
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("构建上传请求失败: %w"), err)
|
||
}
|
||
req.ContentLength = fileSize
|
||
req.Header.Set("Content-Type", mimeType)
|
||
|
||
httpClient := &http.Client{Timeout: 5 * time.Minute}
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("上传失败: %w"), err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||
return fmt.Errorf(i18n.T("OSS 上传失败 HTTP %d: %s"), resp.StatusCode, string(body))
|
||
}
|
||
|
||
// Step 3: 插入块到文档
|
||
fmt.Fprintln(os.Stderr, i18n.T("步骤 3/3: 插入块到文档..."))
|
||
element := buildDocAttachmentElement(mimeType, fileName, resourceID, resourceURL, fileSize)
|
||
insertArgs := map[string]any{
|
||
"nodeId": nodeID,
|
||
"element": element,
|
||
}
|
||
if cmd.Flags().Changed("index") {
|
||
if v, _ := cmd.Flags().GetInt("index"); v >= 0 {
|
||
insertArgs["index"] = v
|
||
}
|
||
}
|
||
if v, _ := cmd.Flags().GetString("where"); v != "" {
|
||
insertArgs["where"] = v
|
||
}
|
||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||
insertArgs["referenceBlockId"] = v
|
||
}
|
||
insertResult, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||
cobracmd.LegacyCommandPath(cmd), "doc", "insert_document_block", insertArgs,
|
||
))
|
||
if err != nil {
|
||
return fmt.Errorf(i18n.T("插入块失败: %w"), err)
|
||
}
|
||
return writeCommandPayload(cmd, insertResult)
|
||
}
|
||
|
||
// extractDocAttachmentUploadInfo 从 get_doc_attachment_upload_info 的返回中
|
||
// 抽出 uploadUrl / resourceId / resourceUrl 三项。返回结构兼容 content.data
|
||
// 和 data 两种包装层次(开源 runner 与 wukong 实测均见过)。
|
||
func extractDocAttachmentUploadInfo(resp map[string]any) (uploadURL, resourceID, resourceURL string, err error) {
|
||
if resp == nil {
|
||
err = apperrors.NewValidation(i18n.T("get_doc_attachment_upload_info 返回空"))
|
||
return
|
||
}
|
||
src := resp
|
||
if content, ok := src["content"].(map[string]any); ok && len(content) > 0 {
|
||
src = content
|
||
}
|
||
data, _ := src["data"].(map[string]any)
|
||
if data == nil {
|
||
data = src
|
||
}
|
||
uploadURL, _ = data["uploadUrl"].(string)
|
||
resourceID, _ = data["resourceId"].(string)
|
||
resourceURL, _ = data["resourceUrl"].(string)
|
||
if uploadURL == "" || resourceID == "" {
|
||
err = apperrors.NewValidation(i18n.T("返回数据缺少 uploadUrl 或 resourceId"))
|
||
return
|
||
}
|
||
return
|
||
}
|
||
|
||
// buildDocAttachmentElement 按文件类型生成 insert_document_block 需要的 element 结构。
|
||
// 图片 ≤ 20MB 走内联图片,否则走附件块。
|
||
func buildDocAttachmentElement(mimeType, fileName, resourceID, resourceURL string, fileSize int64) map[string]any {
|
||
if strings.HasPrefix(mimeType, "image/") && resourceURL != "" && fileSize <= docMaxInlineImageSize {
|
||
return map[string]any{
|
||
"blockType": "paragraph",
|
||
"paragraph": map[string]any{"text": ""},
|
||
"children": []any{
|
||
map[string]any{
|
||
"elementType": "image",
|
||
"properties": map[string]any{"src": resourceURL},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
viewType := "preview"
|
||
if mimeType == "text/markdown" {
|
||
viewType = "summary"
|
||
}
|
||
return map[string]any{
|
||
"blockType": "attachment",
|
||
"attachment": map[string]any{
|
||
"resourceId": resourceID,
|
||
"type": mimeType,
|
||
"name": fileName,
|
||
"viewType": viewType,
|
||
},
|
||
}
|
||
}
|