Compare commits

...
9 Commits
28 changed files with 1999 additions and 145 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Document shortcut reliability** — adds bounded pagination for document and template listings, supports verified paragraph or heading insertion before a reference block, tolerates service-only Markdown layout normalization during write verification, and resolves and verifies the default “My Documents” import target.
+1 -1
View File
@@ -255,7 +255,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
}
var publicationErr *outputPublicationError
if err == nil || !stderrors.As(err, &publicationErr) {
err = interrupted
err = interrupted.withCancellationDetail(err)
}
}
if err != nil {
@@ -485,7 +485,7 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
if got, want := len(parameters), 13; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
@@ -496,7 +496,7 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"block-id": "blockId", "after-block-id": "afterBlockId", "before-block-id": "beforeBlockId", "heading-level": "headingLevel", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
@@ -504,7 +504,7 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
for _, name := range []string{"content", "block-id", "after-block-id", "before-block-id", "heading-level", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
@@ -513,6 +513,9 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if got, want := schemaContractStringSlice(parameters["command"]["enum"]), []string{"append", "overwrite", "block_insert_before", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}; !schemaContractJSONEqual(got, want) {
t.Errorf("--command enum = %#v, want %#v", got, want)
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
+24 -1
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
@@ -44,14 +45,36 @@ func interruptionExitCode(sig os.Signal) int {
type processInterruption struct {
signal os.Signal
detail error
}
func (e *processInterruption) Error() string {
return fmt.Sprintf("process interrupted by %s", e.signal)
message := fmt.Sprintf("process interrupted by %s", e.signal)
if e.detail != nil {
return fmt.Sprintf("%s: %v", message, e.detail)
}
return message
}
func (e *processInterruption) Unwrap() error { return context.Canceled }
// withCancellationDetail keeps the signal as the primary process error while
// retaining actionable context from a command that stopped because of that
// signal. Plain context cancellation and nested signal errors add no useful
// detail, and unrelated command failures must not be relabelled as part of the
// interruption. The detail is deliberately not exposed through Unwrap so an
// inner structured error cannot override the signal's exit code or subtype.
func (e *processInterruption) withCancellationDetail(err error) *processInterruption {
if e == nil || err == nil || err == context.Canceled || !errors.Is(err, context.Canceled) {
return e
}
var interrupted *processInterruption
if errors.As(err, &interrupted) {
return e
}
return &processInterruption{signal: e.signal, detail: err}
}
func (e *processInterruption) ExitCode() int {
return interruptionExitCode(e.signal)
}
+92
View File
@@ -14,6 +14,7 @@ import (
"syscall"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
@@ -56,6 +57,20 @@ func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
t.Fatalf("interruption=%v", interrupted)
}
detailed := interrupted.withCancellationDetail(fmt.Errorf("resume with dws doc import get: %w", context.Canceled))
if detailed == interrupted || !errors.Is(detailed, context.Canceled) || !strings.Contains(detailed.Error(), "dws doc import get") {
t.Fatalf("detailed interruption=%v", detailed)
}
typedDetail := interrupted.withCancellationDetail(apperrors.NewInternal("resume import", apperrors.WithCause(context.Canceled)))
if code := apperrors.ExitCode(typedDetail); code != 130 {
t.Fatalf("typed cancellation detail changed interruption exit code to %d", code)
}
if got := interrupted.withCancellationDetail(context.Canceled); got != interrupted {
t.Fatalf("plain cancellation changed interruption: %v", got)
}
if got := interrupted.withCancellationDetail(errors.New("unrelated failure")); got != interrupted {
t.Fatalf("unrelated failure changed interruption: %v", got)
}
terminated := &processInterruption{signal: syscall.SIGTERM}
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
t.Fatalf("termination=%v", terminated)
@@ -66,6 +81,13 @@ func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
}
}
func TestCrossPlatformCoverageProcessInterruptionRejectsNestedDetail(t *testing.T) {
interrupted := &processInterruption{signal: syscall.SIGINT}
if got := interrupted.withCancellationDetail(&processInterruption{signal: syscall.SIGTERM}); got != interrupted {
t.Fatalf("nested interruption changed the primary signal error: %v", got)
}
}
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
signals := make(chan os.Signal, 3)
stopped, escalated := false, make(chan os.Signal, 1)
@@ -139,6 +161,76 @@ func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testin
}
}
func TestCrossPlatformCoverageExecuteSignalPreservesCancellationRecoveryCommand(t *testing.T) {
const recoveryCommand = "dws doc import get --task-id task-1 --workspace my-space"
if mode := os.Getenv("DWS_SIGNAL_RECOVERY_HELPER"); mode != "" {
installSignalExecuteSeams(t, mode == "json", os.Stdout, os.Stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
_, _ = fmt.Fprintln(os.Stderr, "READY")
<-cmd.Context().Done()
return cmd, fmt.Errorf("导入轮询被取消: %w;任务已经提交,可使用 %s 继续查询", cmd.Context().Err(), recoveryCommand)
})
os.Exit(Execute())
}
for _, tc := range []struct {
mode string
}{
{mode: "human"},
{mode: "json"},
} {
t.Run(tc.mode, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageExecuteSignalPreservesCancellationRecoveryCommand$")
cmd.Env = append(os.Environ(), "DWS_SIGNAL_RECOVERY_HELPER="+tc.mode)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
stderrReader := bufio.NewReader(stderr)
ready, err := stderrReader.ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "READY" {
t.Fatalf("helper readiness failed: %q, err=%v", ready, err)
}
if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
}
stdoutPayload, stdoutErr := io.ReadAll(stdout)
stderrPayload, stderrErr := io.ReadAll(stderrReader)
if stdoutErr != nil || stderrErr != nil {
t.Fatalf("read helper output: stdout=%v stderr=%v", stdoutErr, stderrErr)
}
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != 130 {
t.Fatalf("wait error=%v, want exit 130", waitErr)
}
if tc.mode == "json" {
var env output.Envelope
if err := json.Unmarshal(stdoutPayload, &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdoutPayload)
}
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != "cancelled_by_user" || env.Error.ExitCode != 130 || !strings.Contains(env.Error.Message, "process interrupted by interrupt") || !strings.Contains(env.Error.Message, recoveryCommand) {
t.Fatalf("error=%+v, want cancellation with recovery command", env.Error)
}
return
}
if !strings.Contains(string(stderrPayload), "process interrupted by interrupt") || !strings.Contains(string(stderrPayload), recoveryCommand) {
t.Fatalf("stderr=%q, want recovery command", stderrPayload)
}
})
}
}
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
for _, tc := range []struct {
signal syscall.Signal
@@ -402,7 +402,9 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.get_document_content --start-block-id": "Runtime extension sends startBlockId for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_content --tags": "Runtime extension sends tags for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_style --node": "Reviewed unpinned adapter: doc.get_document_style has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.import_get --folder": "Composite recovery verification input; it is used only by the get_document_info readback after query_import_task completes.",
"doc.import_get --task-id": "Reviewed unpinned adapter: doc.import_get has no singular pinned interface_ref; --task-id is a CLI wrapper input and does not publish a direct interface property.",
"doc.import_get --workspace": "Composite recovery verification input; it is used only by the get_document_info readback after query_import_task completes.",
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
"doc.insert_document_block --level": "aggregate convenience input used to build element",
+11 -6
View File
@@ -4285,8 +4285,8 @@ CLI 内部自动完成全部流程:
3. 确认导入(触发格式转换)
4. 渐进式退避轮询等待完成(最多约 5 分钟)
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
dws doc import get --task-id <taskId>`,
如果轮询超时或中断,会输出包含原目标的完整命令供后续手动查询,例如:
dws doc import get --task-id <taskId> --workspace <原目标WORKSPACE_ID>`,
Example: ` # 导入 Word 文档
dws doc import --file ./report.docx
@@ -4316,13 +4316,16 @@ CLI 内部自动完成全部流程:
Short: "查询导入任务结果(手动兜底)",
Long: `根据 taskId 查询文档导入任务的执行结果。
通常不需要手动调用,dws doc import 会自动完成轮询。
仅在导入命令超时或中断后,用于手动查询任务状态。
仅在导入命令超时或中断后,用于手动查询任务状态。建议直接复制导入结果
中的完整 next_command;其中携带的原目标(--folder 或 --workspace)用于在
completed 后回读验证真实落点。只传 taskId 仍可查询 processing/failed,
但 completed 时会返回未验证错误,不会误报成功。
任务状态:
processing 转换中
completed 导入成功,返回 documentUrl
failed 导入失败`,
Example: ` dws doc import get --task-id <TASK_ID>`,
Example: ` dws doc import get --task-id <TASK_ID> --workspace <WORKSPACE_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runImportGetCommand(cmd, docImportFlowConfig())
},
@@ -4349,13 +4352,15 @@ CLI 内部自动完成全部流程:
},
Selection: contract.SelectionSpec{
AgentSummary: "根据 taskId 查询文档导入任务的执行结果",
UseWhen: []string{"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"},
UseWhen: []string{"已有 doc import 超时或中断结果及其完整 next_command,需要续查同一 taskId 并验证原 folder/workspace 落点时"},
AvoidWhen: []string{"发起导入用 doc import(若入口可用);不要用本命令代替导入"},
Examples: []string{"dws doc import get --task-id <TASK_ID> --format json"},
Examples: []string{"dws doc import get --task-id <TASK_ID> --workspace <WORKSPACE_ID> --format json"},
},
},
})
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
importGetCmd.Flags().String("folder", "", "原导入目标文件夹 ID 或 URL(completed 后落点验证需要)")
importGetCmd.Flags().String("workspace", "", "原导入目标知识库 ID 或 URL(completed 后落点验证需要)")
importCmd.AddCommand(importGetCmd)
newHybridGroupCommand(importCmd)
+1 -1
View File
@@ -683,7 +683,7 @@ func TestCrossPlatformCoverageDocExportImportCommandEdges(t *testing.T) {
{"failed empty", []scriptedToolStep{{text: `{"status":"failed"}`}}, false},
} {
t.Run("import get "+tc.name, func(t *testing.T) {
_ = run(t, tc.steps, tc.dry, "import", "get", "--task-id=task")
_ = run(t, tc.steps, tc.dry, "import", "get", "--task-id=task", "--workspace=workspace")
})
}
}
+457
View File
@@ -0,0 +1,457 @@
// 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 (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func fastDocImportConfig() importFlowConfig {
cfg := docImportFlowConfig()
cfg.poll.maxPolls = 2
cfg.poll.interval = func(int) time.Duration { return 0 }
cfg.poll.wait = func(context.Context, time.Duration) error { return nil }
return cfg
}
func executeDocImportCommand(t *testing.T, caller *sheetImportCaller, cfg importFlowConfig, args ...string) (string, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
SetHTTPPutFile(nil)
})
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
os.Args = []string{"dws", "doc"}
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
command := &cobra.Command{
Use: "import",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, positional []string) error {
return runImportCommand(cmd, positional, cfg)
},
}
command.Flags().String("file", "", "")
command.Flags().String("folder", "", "")
command.Flags().String("folder-id", "", "")
command.Flags().String("workspace", "", "")
command.Flags().String("workspace-id", "", "")
command.Flags().String("name", "", "")
command.SetArgs(args)
err := command.Execute()
return output.String(), err
}
func TestCrossPlatformCoverageDocImportDefaultTargetIsResolvedAndVerified(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"my-space","name":"我的文档"}]}}`},
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1","documentName":"sales","documentType":"ALIDOC"}`},
"get_document_info": {`{"result":{"nodeId":"node-1","workspaceId":"my-space","folderId":"root-folder","name":"sales","contentType":"ALIDOC"}}`},
}}
output, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath)
if err != nil {
t.Fatalf("doc import: %v\n%s", err, output)
}
if len(caller.calls) != 5 {
t.Fatalf("calls = %#v, want 5", caller.calls)
}
wantTools := []string{"list_wikiSpaces", "create_import_session", "confirm_import", "query_import_task", "get_document_info"}
for index, want := range wantTools {
if got := caller.calls[index].tool; got != want {
t.Fatalf("call[%d] tool = %q, want %q", index, got, want)
}
}
if got := caller.calls[1].args["workspaceId"]; got != "my-space" {
t.Fatalf("create_import_session workspaceId = %#v", got)
}
var result map[string]any
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("decode output: %v\n%s", err, output)
}
if result["nodeId"] != "node-1" || result["verified"] != true {
t.Fatalf("result = %#v", result)
}
target, _ := result["target"].(map[string]any)
if target["source"] != "default_personal_workspace" || target["workspaceId"] != "my-space" {
t.Fatalf("target = %#v", target)
}
}
func TestDocImportExplicitFolderSkipsDefaultResolution(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1"}`},
"get_document_info": {`{"nodeId":"node-1","folderId":"folder-1","workspaceId":"space-1"}`},
}}
output, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath, "--folder", "folder-1")
if err != nil {
t.Fatalf("doc import: %v\n%s", err, output)
}
for _, call := range caller.calls {
if call.tool == "list_wikiSpaces" {
t.Fatalf("explicit target unexpectedly resolved default: %#v", caller.calls)
}
}
if got := caller.calls[0].args["targetFolderId"]; got != "folder-1" {
t.Fatalf("targetFolderId = %#v", got)
}
}
func TestDocImportDefaultResolutionFailureIsNotStarted(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"list_wikiSpaces": {`{"result":{"wikiSpaces":[]}}`},
}}
_, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath)
if err == nil {
t.Fatal("expected resolution failure")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Reason != "doc_import_default_target_unavailable" || structured.FailureStage != "resolve_default_target" {
t.Fatalf("structured error = %#v", structured)
}
if structured.ExecutionStarted == nil || *structured.ExecutionStarted {
t.Fatalf("ExecutionStarted = %#v, want false", structured.ExecutionStarted)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_wikiSpaces" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportCancellationReturnsExecutableRecoveryCommand(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"list_wikiSpaces": {`{"result":{"wikiSpaces":[{"workspaceId":"my-space","name":"我的文档"}]}}`},
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
}}
cfg := fastDocImportConfig()
cfg.poll.wait = func(context.Context, time.Duration) error { return context.Canceled }
_, err := executeDocImportCommand(t, caller, cfg, "--file", filePath)
if err == nil {
t.Fatal("expected cancellation error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v, want errors.Is(context.Canceled)", err)
}
for _, want := range []string{
"导入轮询被取消",
"dws doc import get --task-id task-1 --workspace my-space",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("cancellation error = %q, want %q", err, want)
}
}
if len(caller.calls) != 3 || caller.calls[0].tool != "list_wikiSpaces" || caller.calls[2].tool != "confirm_import" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportPlacementMismatchIsPartialSuccess(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1"}`},
"get_document_info": {`{"nodeId":"node-1","folderId":"wrong-folder"}`},
}}
_, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath, "--folder", "folder-1")
if err == nil {
t.Fatal("expected placement verification failure")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Reason != "doc_import_placement_unverified" || structured.Details["status"] != "partial_success" {
t.Fatalf("structured error = %#v", structured)
}
if structured.ExecutionStarted == nil || !*structured.ExecutionStarted {
t.Fatalf("ExecutionStarted = %#v, want true", structured.ExecutionStarted)
}
}
func TestCrossPlatformCoverageParsePersonalDocWorkspaceIDRejectsAmbiguousResponse(t *testing.T) {
for _, text := range []string{
`{`,
`{"wikiSpaces":[]}`,
`{"wikiSpaces":"not-a-list"}`,
`{"wikiSpaces":[1]}`,
`{"wikiSpaces":[{"workspaceId":"a"},{"workspaceId":"b"}]}`,
`{"wikiSpaces":[{"name":"我的文档"}]}`,
} {
if _, err := parsePersonalDocWorkspaceID(text); err == nil {
t.Fatalf("parsePersonalDocWorkspaceID(%s) unexpectedly succeeded", text)
}
}
}
func TestCrossPlatformCoverageDocImportTargetDefensiveBranches(t *testing.T) {
if err := resolveDefaultDocImportTarget(context.Background(), nil); err != nil {
t.Fatalf("nil import target: %v", err)
}
for _, file := range []*preparedImportFile{{folder: "folder-1"}, {workspace: "space-1"}} {
if err := resolveDefaultDocImportTarget(context.Background(), file); err != nil {
t.Fatalf("explicit import target: %v", err)
}
}
for _, text := range []string{`{`, `{}`, `[]`} {
if _, err := parseImportedDocumentInfo(text); err == nil {
t.Fatalf("parseImportedDocumentInfo(%q) unexpectedly succeeded", text)
}
}
if info, err := parseImportedDocumentInfo(`{"data":{"document":{"nodeId":"node-1"}}}`); err != nil || info["nodeId"] != "node-1" {
t.Fatalf("nested document info = %#v, %v", info, err)
}
for _, test := range []struct {
raw string
want string
}{
{"https://alidocs.dingtalk.com/i/nodes/n?workspaceId=space-query", "space-query"},
{"https://alidocs.dingtalk.com/i/nodes/node-path", "node-path"},
{"https://alidocs.dingtalk.com/i/spaces/space-path", "space-path"},
{"https://alidocs.dingtalk.com/i/folders/folder-path", "folder-path"},
{"https://alidocs.dingtalk.com/unknown/path", "https://alidocs.dingtalk.com/unknown/path"},
{"plain-id", "plain-id"},
} {
if got := canonicalImportTargetID(test.raw); got != test.want {
t.Fatalf("canonicalImportTargetID(%q) = %q, want %q", test.raw, got, test.want)
}
}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
verifyFailure := func(t *testing.T, file preparedImportFile, response, documentURL string) {
t.Helper()
responses := map[string][]string{}
if response != "" {
responses["get_document_info"] = []string{response}
}
InitDeps(&sheetImportCaller{responses: responses})
if _, _, err := verifyImportedDocumentPlacement(context.Background(), file, "task-1", documentURL); err == nil {
t.Fatal("placement verification unexpectedly succeeded")
}
}
verifyFailure(t, preparedImportFile{}, "", "")
verifyFailure(t, preparedImportFile{}, "", "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{}, `{`, "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{}, `{}`, "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{}, `{"nodeId":"other"}`, "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{workspace: "space-1"}, `{"nodeId":"node-1","workspaceId":"wrong"}`, "https://alidocs.dingtalk.com/i/nodes/node-1")
}
func TestCrossPlatformCoverageDocImportGetVerifiesOriginalTarget(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2"}`},
"get_document_info": {`{"result":{"nodeId":"node-2","workspaceId":"space-2","name":"report"}}`},
}}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "space-2", "")
if err := runImportGetCommand(cmd, docImportFlowConfig()); err != nil {
t.Fatalf("doc import get: %v", err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "query_import_task" || caller.calls[1].tool != "get_document_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var result map[string]any
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if result["nodeId"] != "node-2" || result["verified"] != true {
t.Fatalf("result = %#v", result)
}
target, _ := result["target"].(map[string]any)
if target["source"] != "workspace_flag" || target["workspaceId"] != "space-2" {
t.Fatalf("target = %#v", target)
}
}
func TestCrossPlatformCoverageDocImportGetTaskIDOnlyQueriesProcessing(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"processing","taskId":"task-2"}`},
}}
if err := runDocCoverageCommand(t, caller, "import", "get", "--task-id=task-2"); err != nil {
t.Fatalf("taskId-only processing query: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "query_import_task" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportGetCompletedWithoutTargetIsUnverified(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2"}`},
}}
InitDeps(caller)
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "", "")
err := runImportGetCommand(cmd, docImportFlowConfig())
if err == nil {
t.Fatal("completed task without verification target unexpectedly succeeded")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Reason != "doc_import_verification_target_required" || structured.Details["taskStatus"] != "completed" || structured.Details["verified"] != false || structured.Details["nodeId"] != "node-2" {
t.Fatalf("structured error = %#v", structured)
}
if structured.ExecutionStarted == nil || !*structured.ExecutionStarted {
t.Fatalf("ExecutionStarted = %#v, want true", structured.ExecutionStarted)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "query_import_task" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportGetInvalidJSONFailsClosed(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`not-json`},
}}
InitDeps(caller)
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "space-2", "")
err := runImportGetCommand(cmd, docImportFlowConfig())
if err == nil {
t.Fatal("invalid query response unexpectedly succeeded")
}
for _, want := range []string{
"解析导入任务响应失败",
"dws doc import get --task-id task-2 --workspace space-2",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want %q", err, want)
}
}
}
func TestCrossPlatformCoverageDocImportGetDryRunIncludesTarget(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
caller := &sheetImportCaller{dryRun: true}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "space-2", "")
if err := runImportGetCommand(cmd, docImportFlowConfig()); err != nil {
t.Fatalf("doc import get dry-run: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run reached MCP: %#v", caller.calls)
}
var result map[string]any
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if result["dry_run"] != true || result["executed"] != false {
t.Fatalf("result = %#v", result)
}
target, _ := result["target"].(map[string]any)
if target["source"] != "workspace_flag" || target["workspaceId"] != "space-2" {
t.Fatalf("target = %#v", target)
}
}
func TestCrossPlatformCoverageDocImportRecoveryCommandCarriesEveryTarget(t *testing.T) {
got := importRecoveryCommand(docImportFlowConfig(), "task-1", preparedImportFile{
folder: "folder;unsafe", workspace: "https://alidocs.test/space?id=1&kind=doc",
})
for _, want := range []string{
"dws doc import get --task-id task-1",
"--folder 'folder;unsafe'",
"--workspace 'https://alidocs.test/space?id=1&kind=doc'",
} {
if !strings.Contains(got, want) {
t.Fatalf("recovery command = %q, want %q", got, want)
}
}
}
+15
View File
@@ -251,6 +251,14 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
return cleaned, nil
}
// PrepareDocJSONMLBody exposes the shared strict JSONML write pipeline to
// higher-level Doc commands. It validates a complete document body and
// requires a root node; optional repair remains controlled by the caller's
// --fix-jsonml flag when that flag exists.
func PrepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
return prepareJsonMLBody(cmd, raw)
}
// prepareJsonMLNode processes the JSONML array passed to `doc block
// insert/update --element` (a single JSONML block node, not a body).
//
@@ -343,6 +351,13 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) {
return stripInputUnsafeChars(string(out)), nil
}
// PrepareDocJSONMLNode exposes the shared strict JSONML write pipeline for a
// single block node. Unlike PrepareDocJSONMLBody, it does not require a root
// document node.
func PrepareDocJSONMLNode(cmd *cobra.Command, raw string) (string, error) {
return prepareJsonMLNode(cmd, raw)
}
func emitFixNotes(notes []string) {
if len(notes) == 0 {
return
+2 -2
View File
@@ -76,7 +76,7 @@ func TestCrossPlatformCoverageJSONMLInputSanitizingAndShapeCoercion(t *testing.T
func TestCrossPlatformCoveragePrepareJSONMLBody(t *testing.T) {
strict := jsonMLTestCommand(t, false)
valid := `{"jsonml":["root",{},["p",{},["span",{},"ok"]]]}`
if got, err := prepareJsonMLBody(strict, valid); err != nil || !strings.Contains(got, `"root"`) {
if got, err := PrepareDocJSONMLBody(strict, valid); err != nil || !strings.Contains(got, `"root"`) {
t.Fatalf("valid wrapper = %q, %v", got, err)
}
if got, err := prepareJsonMLBody(strict, `["root",{},["p",{},["span",{},"ok"]]]`); err != nil || !strings.Contains(got, `"root"`) {
@@ -113,7 +113,7 @@ func TestCrossPlatformCoveragePrepareJSONMLNode(t *testing.T) {
`["p",{},["span",{},"ok"]]`,
`{"jsonml":[["p",{},["span",{},"ok"]]]}`,
} {
if got, err := prepareJsonMLNode(strict, raw); err != nil || !strings.Contains(got, `"p"`) {
if got, err := PrepareDocJSONMLNode(strict, raw); err != nil || !strings.Contains(got, `"p"`) {
t.Errorf("prepareJsonMLNode(%q) = %q, %v", raw, got, err)
}
}
+374 -17
View File
@@ -25,6 +25,7 @@ import (
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -55,6 +56,8 @@ type importFlowConfig struct {
requireTarget bool
serverID string
includeNodeID bool
resolveDefaultTarget bool
verifyPlacement bool
timeoutAsResult bool
nextCommand string
poll importPollPolicy
@@ -72,6 +75,7 @@ type preparedImportFile struct {
size int64
folder string
workspace string
target string
}
func defaultImportPollPolicy() importPollPolicy {
@@ -101,6 +105,9 @@ func docImportFlowConfig() importFlowConfig {
supportedFormatsText: "docx, doc, xlsx, xls, md, txt, xmind, mark",
folderFlags: []string{"folder", "folder-id"},
workspaceFlags: []string{"workspace", "workspace-id"},
includeNodeID: true,
resolveDefaultTarget: true,
verifyPlacement: true,
nextCommand: "dws doc import get --task-id %s",
poll: defaultImportPollPolicy(),
// 白名单外的格式改走文档空间的文件上传链路
@@ -193,6 +200,13 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
return preparedImportFile{}, fmt.Errorf("--folder-token 与 --workspace 至少需要提供一个(导入目标位置)")
}
target := ""
if folder != "" {
target = "folder_flag"
} else if workspace != "" {
target = "workspace_flag"
}
return preparedImportFile{
path: filePath,
name: name,
@@ -200,9 +214,275 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
size: fileInfo.Size(),
folder: folder,
workspace: workspace,
target: target,
}, nil
}
func resolveDefaultDocImportTarget(ctx context.Context, file *preparedImportFile) error {
if file == nil || file.folder != "" || file.workspace != "" {
return nil
}
text, err := callMCPToolReturnTextOnServer(ctx, "wiki", "list_wikiSpaces", map[string]any{
"wikiSpaceType": "myWikiSpace",
})
if err != nil {
return docImportTargetResolutionError(err)
}
workspaceID, err := parsePersonalDocWorkspaceID(text)
if err != nil {
return docImportTargetResolutionError(err)
}
file.workspace = workspaceID
file.target = "default_personal_workspace"
return nil
}
func parsePersonalDocWorkspaceID(text string) (string, error) {
var root any
if err := json.Unmarshal([]byte(text), &root); err != nil {
return "", fmt.Errorf("解析我的文档空间响应失败: %w", err)
}
spaces, ok := findImportObjectList(root, "wikiSpaces", "spaces")
if !ok || len(spaces) == 0 {
return "", fmt.Errorf("我的文档空间响应没有返回 wikiSpaces")
}
if len(spaces) != 1 {
return "", fmt.Errorf("我的文档空间响应返回 %d 个候选,无法安全选择", len(spaces))
}
workspaceID := importString(spaces[0], "workspaceId")
if workspaceID == "" {
return "", fmt.Errorf("我的文档空间响应缺少 workspaceId")
}
return workspaceID, nil
}
func findImportObjectList(value any, keys ...string) ([]map[string]any, bool) {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
raw, exists := typed[key]
if !exists {
continue
}
items, ok := raw.([]any)
if !ok {
return nil, false
}
objects := make([]map[string]any, 0, len(items))
for _, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, false
}
objects = append(objects, object)
}
return objects, true
}
for _, key := range []string{"result", "data"} {
if nested, exists := typed[key]; exists {
if objects, ok := findImportObjectList(nested, keys...); ok {
return objects, true
}
}
}
}
return nil, false
}
func docImportTargetResolutionError(cause error) error {
return apperrors.NewAPI(
"无法解析默认的“我的文档”目标,导入尚未开始",
apperrors.WithOperation("doc.import"),
apperrors.WithReason("doc_import_default_target_unavailable"),
apperrors.WithFailureStage("resolve_default_target"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions("检查是否可访问“我的文档”空间", "也可以显式传入 --folder 或 --workspace 后重试"),
apperrors.WithDetails(map[string]any{"status": "failed", "target": "myWikiSpace"}),
apperrors.WithCause(cause),
)
}
func importString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func parseImportedDocumentInfo(text string) (map[string]any, error) {
var root any
if err := json.Unmarshal([]byte(text), &root); err != nil {
return nil, fmt.Errorf("解析文档元信息响应失败: %w", err)
}
if info, ok := findImportedDocumentInfo(root); ok {
return info, nil
}
return nil, fmt.Errorf("文档元信息响应缺少 nodeId/folderId/workspaceId")
}
func findImportedDocumentInfo(value any) (map[string]any, bool) {
object, ok := value.(map[string]any)
if !ok {
return nil, false
}
if importString(object, "nodeId", "fileId", "dentryUuid", "folderId", "workspaceId") != "" {
return object, true
}
for _, key := range []string{"result", "data", "documentInfo", "document", "doc", "file"} {
if nested, exists := object[key]; exists {
if info, ok := findImportedDocumentInfo(nested); ok {
return info, true
}
}
}
return nil, false
}
func canonicalImportTargetID(raw string) string {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme == "" && parsed.Host == "") {
return raw
}
for _, key := range []string{"workspaceId", "spaceId", "folderId", "nodeId"} {
if value := strings.TrimSpace(parsed.Query().Get(key)); value != "" {
return value
}
}
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for index, segment := range segments {
if index+1 >= len(segments) {
break
}
switch segment {
case "nodes", "spaces", "folders":
if value := strings.TrimSpace(segments[index+1]); value != "" {
return value
}
}
}
return raw
}
func verifyImportedDocumentPlacement(ctx context.Context, file preparedImportFile, taskID, documentURL string) (string, map[string]any, error) {
nodeID := extractNodeIDFromDocURL(documentURL)
if nodeID == "" {
return "", nil, docImportPlacementError(file, taskID, "", nil, fmt.Errorf("导入结果缺少可解析的 documentUrl"))
}
return verifyImportedNodePlacement(ctx, file, taskID, nodeID)
}
func verifyImportedNodePlacement(ctx context.Context, file preparedImportFile, taskID, nodeID string) (string, map[string]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_info", map[string]any{"nodeId": nodeID})
if err != nil {
return nodeID, nil, docImportPlacementError(file, taskID, nodeID, nil, err)
}
info, err := parseImportedDocumentInfo(text)
if err != nil {
return nodeID, nil, docImportPlacementError(file, taskID, nodeID, nil, err)
}
observedNodeID := importString(info, "nodeId", "fileId", "dentryUuid", "id")
if observedNodeID != "" && observedNodeID != nodeID {
return nodeID, info, docImportPlacementError(file, taskID, nodeID, info,
fmt.Errorf("回读 nodeId=%s,与导入结果 nodeId=%s 不一致", observedNodeID, nodeID))
}
if file.folder != "" {
expected := canonicalImportTargetID(file.folder)
observed := importString(info, "folderId")
if observed == "" || observed != expected {
return nodeID, info, docImportPlacementError(file, taskID, nodeID, info,
fmt.Errorf("目标文件夹验证失败:expected=%s observed=%s", expected, observed))
}
}
if file.workspace != "" {
expected := canonicalImportTargetID(file.workspace)
observed := importString(info, "workspaceId")
if observed == "" || observed != expected {
return nodeID, info, docImportPlacementError(file, taskID, nodeID, info,
fmt.Errorf("目标知识库验证失败:expected=%s observed=%s", expected, observed))
}
}
return nodeID, compactImportedDocumentInfo(info, nodeID), nil
}
func compactImportedDocumentInfo(info map[string]any, nodeID string) map[string]any {
result := map[string]any{"nodeId": nodeID}
for _, key := range []string{"folderId", "workspaceId", "name", "contentType"} {
if value := importString(info, key); value != "" {
result[key] = value
}
}
return result
}
func importTargetSummary(file preparedImportFile) map[string]any {
result := map[string]any{"source": file.target}
if file.folder != "" {
result["folderId"] = canonicalImportTargetID(file.folder)
}
if file.workspace != "" {
result["workspaceId"] = canonicalImportTargetID(file.workspace)
}
return result
}
func docImportPlacementError(file preparedImportFile, taskID, nodeID string, observed map[string]any, cause error) error {
details := map[string]any{
"status": "partial_success",
"nodeId": nodeID,
"target": importTargetSummary(file),
"verified": false,
}
if taskID != "" {
details["taskId"] = taskID
}
if observed != nil {
details["observed"] = compactImportedDocumentInfo(observed, nodeID)
}
return apperrors.NewAPI(
"导入或上传已经完成,但目标落点回读验证失败;为避免重复创建,请先按 nodeId 检查文档",
apperrors.WithOperation("doc.import"),
apperrors.WithReason("doc_import_placement_unverified"),
apperrors.WithFailureStage("verify_placement"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("按 nodeId 检查文档当前位置", "确认导入结果前不要重复执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
func docImportVerificationTargetRequiredError(taskID, documentURL string) error {
details := map[string]any{
"taskId": taskID,
"taskStatus": "completed",
"verified": false,
}
if documentURL != "" {
details["documentUrl"] = documentURL
}
if nodeID := extractNodeIDFromDocURL(documentURL); nodeID != "" {
details["nodeId"] = nodeID
}
return apperrors.NewAPI(
"导入任务已经完成,但未提供原导入目标,无法验证真实落点",
apperrors.WithOperation("doc.import"),
apperrors.WithReason("doc_import_verification_target_required"),
apperrors.WithFailureStage("verify_placement"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions(
"使用原 --folder 或 --workspace 重新执行当前 doc import get 查询",
"若无法确认原目标,请按 nodeId 检查文档位置,并避免重复导入",
),
apperrors.WithDetails(details),
)
}
func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args map[string]any) (string, error) {
if cfg.serverID != "" {
return callMCPToolReturnTextOnServer(ctx, cfg.serverID, toolName, args)
@@ -222,7 +502,7 @@ func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file prep
label = "无扩展名"
}
deps.Out.PrintWarning(fmt.Sprintf(
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入解析出的文档目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
label, cfg.supportedFormatsText))
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
@@ -276,7 +556,14 @@ func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file prep
if err != nil {
return err
}
return deps.Out.PrintJSON(map[string]any{
var verification map[string]any
if cfg.verifyPlacement {
_, verification, err = verifyImportedNodePlacement(ctx, file, "", dentryID)
if err != nil {
return err
}
}
result := map[string]any{
"success": true,
"operation": "上传文件到钉钉文档",
"requested_operation": cfg.operation,
@@ -286,7 +573,13 @@ func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file prep
"format": file.extension,
"dentry_id": dentryID,
"result": commit,
})
}
if cfg.verifyPlacement {
result["verified"] = true
result["target"] = importTargetSummary(file)
result["verification"] = verification
}
return deps.Out.PrintJSON(result)
}
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
@@ -322,13 +615,13 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if err != nil {
return err
}
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
return runImportUploadFallback(cmd, cfg, file)
}
uploadFallback := cfg.uploadFallback && !cfg.supportedFormats[file.extension]
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
if uploadFallback {
return runImportUploadFallback(cmd, cfg, file)
}
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
@@ -353,6 +646,16 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if ctx == nil {
ctx = context.Background()
}
if cfg.resolveDefaultTarget && file.folder == "" && file.workspace == "" {
if err := resolveDefaultDocImportTarget(ctx, &file); err != nil {
return err
}
}
// 实际执行时,白名单外格式也必须先完成与在线转换路径相同的目标解析,
// 再进入上传回退;这样无显式目标时仍落到“我的文档”,且可执行同一套回读验证。
if uploadFallback {
return runImportUploadFallback(cmd, cfg, file)
}
if !jsonMode {
deps.Out.PrintInfo("[1/4] 创建导入会话...")
@@ -428,7 +731,7 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if err != nil {
var timeoutErr *importPollTimeoutError
if !errors.As(err, &timeoutErr) {
return err
return fmt.Errorf("%w;任务已经提交,可使用 %s 继续查询", err, importRecoveryCommand(cfg, taskID, file))
}
if cfg.timeoutAsResult {
if !jsonMode {
@@ -439,15 +742,24 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
"timed_out": true,
"taskId": taskID,
"status": "processing",
"next_command": fmt.Sprintf(cfg.nextCommand, taskID),
"next_command": importRecoveryCommand(cfg, taskID, file),
})
}
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID))
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), importRecoveryCommand(cfg, taskID, file))
}
documentURL, _ := result["documentUrl"].(string)
documentName, _ := result["documentName"].(string)
documentType, _ := result["documentType"].(string)
nodeID := extractNodeIDFromDocURL(documentURL)
var verification map[string]any
if cfg.verifyPlacement {
var err error
nodeID, verification, err = verifyImportedDocumentPlacement(ctx, file, taskID, documentURL)
if err != nil {
return err
}
}
finalResult := map[string]any{
"success": true,
"taskId": taskID,
@@ -456,7 +768,12 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
"documentType": documentType,
}
if cfg.includeNodeID {
finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL)
finalResult["nodeId"] = nodeID
}
if cfg.verifyPlacement {
finalResult["verified"] = true
finalResult["target"] = importTargetSummary(file)
finalResult["verification"] = verification
}
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
@@ -464,20 +781,47 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
return deps.Out.PrintJSON(finalResult)
}
func importRecoveryCommand(cfg importFlowConfig, taskID string, file preparedImportFile) string {
command := fmt.Sprintf(cfg.nextCommand, ShellQuoteArg(taskID))
if !cfg.verifyPlacement {
return command
}
if file.folder != "" {
command += " --folder " + ShellQuoteArg(file.folder)
}
if file.workspace != "" {
command += " --workspace " + ShellQuoteArg(file.workspace)
}
return command
}
func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
taskID := mustGetFlag(cmd, "task-id")
if taskID == "" {
return fmt.Errorf("flag --task-id is required")
}
target := preparedImportFile{
folder: importFlagValue(cmd, cfg.folderFlags...),
workspace: importFlagValue(cmd, cfg.workspaceFlags...),
}
if target.folder != "" {
target.target = "folder_flag"
} else if target.workspace != "" {
target.target = "workspace_flag"
}
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
preview := map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": cfg.queryOperation,
"taskId": taskID,
})
}
if cfg.verifyPlacement {
preview["target"] = importTargetSummary(target)
}
return deps.Out.PrintJSON(preview)
}
deps.Out.PrintKeyValue("操作", cfg.queryOperation)
deps.Out.PrintKeyValue("任务ID", taskID)
@@ -495,15 +839,28 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
var result map[string]any
if err := json.Unmarshal([]byte(text), &result); err != nil {
deps.Out.PrintRaw(text)
return nil
return fmt.Errorf("解析导入任务响应失败 (taskId=%s),请重试 %s: %w", taskID, importRecoveryCommand(cfg, taskID, target), err)
}
status, _ := result["status"].(string)
message, _ := result["message"].(string)
if strings.EqualFold(status, "completed") {
documentURL, _ := result["documentUrl"].(string)
if cfg.verifyPlacement && target.folder == "" && target.workspace == "" {
return docImportVerificationTargetRequiredError(taskID, documentURL)
}
nodeID := extractNodeIDFromDocURL(documentURL)
if cfg.verifyPlacement {
var verification map[string]any
nodeID, verification, err = verifyImportedDocumentPlacement(ctx, target, taskID, documentURL)
if err != nil {
return err
}
result["verified"] = true
result["target"] = importTargetSummary(target)
result["verification"] = verification
}
if cfg.includeNodeID {
documentURL, _ := result["documentUrl"].(string)
result["nodeId"] = extractNodeIDFromDocURL(documentURL)
result["nodeId"] = nodeID
}
return deps.Out.PrintJSON(result)
}
@@ -64,6 +64,7 @@ func TestCrossPlatformCoverageImportFlowRemainingBranches(t *testing.T) {
t.Cleanup(func() { os.Args = oldArgs })
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"wikiSpaces":[{"workspaceId":"my-space"}]}`},
{text: `{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
{text: `{"taskId":"task-1"}`},
{text: `{"status":"processing"}`},
@@ -76,7 +77,7 @@ func TestCrossPlatformCoverageImportFlowRemainingBranches(t *testing.T) {
cfg.poll.interval = func(int) time.Duration { return 0 }
cfg.poll.wait = func(context.Context, time.Duration) error { return nil }
err := runImportCommand(importCoverageCommand(t, writeImportFixture(t, "md")), nil, cfg)
if err == nil || !strings.Contains(err.Error(), "手动查询") {
if err == nil || !strings.Contains(err.Error(), "手动查询") || !strings.Contains(err.Error(), "--workspace my-space") {
t.Fatalf("runImportCommand() error = %v, want manual-query timeout", err)
}
})
@@ -89,6 +90,8 @@ func TestCrossPlatformCoverageImportFlowRemainingBranches(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"status":"processing"}`}}})
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-1", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "workspace-1", "")
if err := runImportGetCommand(cmd, docImportFlowConfig()); err != nil {
t.Fatalf("runImportGetCommand() error = %v", err)
}
+76 -10
View File
@@ -23,6 +23,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -56,6 +57,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
uploadSteps := []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
{text: `{"nodeId":"node-1","workspaceId":"ws-1","folderId":"folder-abc","name":"sales.html"}`},
}
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
@@ -74,20 +76,20 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
if caller.calls != 3 || caller.tool != "get_document_info" {
t.Fatalf("fallback calls = %d last tool = %q, want readback after commit", caller.calls, caller.tool)
}
if got := caller.args["workspaceId"]; got != "ws-1" {
if got := caller.argsLog[1]["workspaceId"]; got != "ws-1" {
t.Fatalf("commit workspaceId = %v, want ws-1", got)
}
if got := caller.args["name"]; got != "sales.html" {
if got := caller.argsLog[1]["name"]; got != "sales.html" {
t.Fatalf("commit name = %v, want original file name with extension", got)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
}
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false || payload["verified"] != true {
t.Fatalf("fallback markers missing: %#v", payload)
}
if payload["dentry_id"] != "node-1" {
@@ -104,6 +106,63 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
}
})
t.Run("fallback resolves and verifies the default personal workspace", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"wikiSpaces":[{"workspaceId":"my-space"}]}`},
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-default","name":"sales.pdf"}`},
{text: `{"nodeId":"node-default","workspaceId":"my-space","name":"sales.pdf"}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "pdf"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
if strings.Join(caller.toolLog, ",") != "list_wikiSpaces,get_file_upload_info,commit_uploaded_file,get_document_info" {
t.Fatalf("fallback calls = %#v", caller.toolLog)
}
if got := caller.argsLog[2]["workspaceId"]; got != "my-space" {
t.Fatalf("default commit workspaceId = %#v", got)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
target, _ := payload["target"].(map[string]any)
if payload["verified"] != true || target["source"] != "default_personal_workspace" || target["workspaceId"] != "my-space" {
t.Fatalf("fallback result = %#v", payload)
}
})
t.Run("fallback does not report success when placement readback mismatches", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-mismatch"}`},
{text: `{"nodeId":"node-mismatch","workspaceId":"wrong-space"}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "pdf"))
_ = cmd.Flags().Set("workspace", "expected-space")
err := runImportCommand(cmd, nil, docImportFlowConfig())
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "doc_import_placement_unverified" {
t.Fatalf("placement mismatch error = %#v", err)
}
if stdout.Len() != 0 {
t.Fatalf("placement mismatch emitted success output: %s", stdout.String())
}
})
t.Run("json dry run stays a single json document", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
installScriptedCaller(t, caller)
@@ -134,11 +193,12 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
if caller.tool != "get_document_info" {
t.Fatalf("%s last tool = %q, want get_document_info", ext, caller.tool)
}
}
})
@@ -151,11 +211,12 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
path := writeImportFixture(t, "HTM")
cmd := htmlFallbackCommand(t, "")
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
if caller.tool != "get_document_info" {
t.Fatalf("last tool = %q, want get_document_info", caller.tool)
}
})
@@ -172,7 +233,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if got := caller.args["folderId"]; got != "folder-abc" {
if got := caller.argsLog[1]["folderId"]; got != "folder-abc" {
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
}
})
@@ -190,6 +251,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, noExt)
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
@@ -298,6 +360,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
_ = cmd.Flags().Set("workspace", "ws-1")
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
@@ -326,6 +389,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
_ = cmd.Flags().Set("workspace", "ws-1")
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
@@ -338,6 +402,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
{text: `{"fileId":"nested-node-9","workspaceId":"ws-1"}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
@@ -346,6 +411,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
+2 -2
View File
@@ -322,14 +322,14 @@ func TestCrossPlatformCoverageSheetImportGetDryRunJSONIsSingleDocument(t *testin
}
}
func TestDocImportConfigPreservesExistingContract(t *testing.T) {
func TestDocImportConfigPreservesFormatsAndEnablesDefaultTargetVerification(t *testing.T) {
cfg := docImportFlowConfig()
for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} {
if !cfg.supportedFormats[ext] {
t.Errorf("doc import no longer supports %s", ext)
}
}
if cfg.requireTarget || cfg.includeNodeID || cfg.timeoutAsResult {
if cfg.requireTarget || !cfg.includeNodeID || !cfg.resolveDefaultTarget || !cfg.verifyPlacement || cfg.timeoutAsResult {
t.Fatalf("doc import compatibility changed: %#v", cfg)
}
}
@@ -93,7 +93,7 @@ func TestWukongSyncDocCommands(t *testing.T) {
requireWukongSyncFlags(t, importCmd, "file", "folder", "workspace", "name", "folder-id", "workspace-id")
importGetCmd := requireWukongSyncCommand(t, root, "import", "get")
requireWukongSyncFlags(t, importGetCmd, "task-id")
requireWukongSyncFlags(t, importGetCmd, "task-id", "folder", "workspace")
}
func TestWukongSyncSheetCommands(t *testing.T) {
+33 -6
View File
@@ -14,7 +14,9 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
const (
@@ -64,10 +66,10 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取 stdin 失败: %v", flag, err))
}
return string(data), nil
return normalizeDocInputLineEndings(string(data)), nil
}
if !strings.HasPrefix(raw, "@") {
return raw, nil
return normalizeDocInputLineEndings(raw), nil
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
@@ -93,7 +95,11 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
return string(data), nil
return normalizeDocInputLineEndings(string(data)), nil
}
func normalizeDocInputLineEndings(content string) string {
return strings.ReplaceAll(strings.ReplaceAll(content, "\r\n", "\n"), "\r", "\n")
}
func validateWorkspaceInputPath(flag, raw string) error {
@@ -120,7 +126,23 @@ func validateWorkspaceInputPath(flag, raw string) error {
return nil
}
func validateJSONML(raw string) (string, error) {
func validateJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
normalized, err := helpers.PrepareDocJSONMLBody(cmd, raw)
if err != nil {
return "", shortcutJSONMLValidationError(err)
}
return validateJSONMLElement(normalized)
}
func validateJSONMLNode(cmd *cobra.Command, raw string) (string, error) {
normalized, err := helpers.PrepareDocJSONMLNode(cmd, raw)
if err != nil {
return "", shortcutJSONMLValidationError(err)
}
return validateJSONMLElement(normalized)
}
func validateJSONMLElement(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
@@ -138,8 +160,13 @@ func validateJSONML(raw string) (string, error) {
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
return raw, nil
}
func shortcutJSONMLValidationError(err error) error {
message := strings.ReplaceAll(err.Error(), "--content-format", "--doc-format")
message = strings.ReplaceAll(message, "--element", "--content")
return apperrors.NewValidation(message)
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
+352 -29
View File
@@ -6,12 +6,14 @@ package doc
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"time"
"unicode"
@@ -23,8 +25,12 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver"
"github.com/yuin/goldmark"
goldmarkast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer/html"
goldmarktext "github.com/yuin/goldmark/text"
goldmarkutil "github.com/yuin/goldmark/util"
)
var (
@@ -84,7 +90,7 @@ var Create = shortcut.Shortcut{
}
format := rt.Str("doc-format")
if format == "jsonml" && content != "" {
content, err = validateJSONML(content)
content, err = validateJSONMLBody(rt.Command(), content)
if err != nil {
return err
}
@@ -180,7 +186,8 @@ var Create = shortcut.Shortcut{
return docVerificationError("doc.create", "verify", nodeID, fmt.Errorf("回读结果与完整初始内容不一致"), append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
data := map[string]any{"nodeId": nodeID, "result": created, "verified": true, "verification": verification}
verificationSummary := compactDocVerification(verification, content, "overwrite", format, nil)
data := map[string]any{"nodeId": nodeID, "result": created, "verified": true, "verification": verificationSummary}
if len(contentChunks) > 1 {
data["chunkPlan"] = chunkPlan.Summary()
}
@@ -370,18 +377,20 @@ var Update = shortcut.Shortcut{
Command: "+update",
Product: productDoc,
Description: "追加、覆盖或按 block 精确更新文档内容",
Intent: "当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
Intent: "当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、在参考 block 前后插入段落或标题、block 替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+update", "追加、覆盖或按 block 精确更新文档内容",
"当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
[]string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
"当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、在参考 block 前后插入段落或标题、block 替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
[]string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_insert_before --before-block-id <BLOCK_ID> --content "发布说明" --heading-level 1`},
contract.ParamDecl{Name: "node", Property: "node"},
contract.ParamDecl{Name: "command", Property: "command"},
contract.ParamDecl{Name: "content", Property: "content"},
contract.ParamDecl{Name: "doc-format", Property: "docFormat"},
contract.ParamDecl{Name: "block-id", Property: "blockId"},
contract.ParamDecl{Name: "after-block-id", Property: "afterBlockId"},
contract.ParamDecl{Name: "before-block-id", Property: "beforeBlockId"},
contract.ParamDecl{Name: "heading-level", Property: "headingLevel"},
contract.ParamDecl{Name: "old", Property: "old"},
contract.ParamDecl{Name: "new", Property: "new"},
contract.ParamDecl{Name: "expected-revision", Property: "expectedRevision"},
@@ -389,16 +398,18 @@ var Update = shortcut.Shortcut{
contract.ParamDecl{Name: "text", Property: "content"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true, Aliases: []string{"doc"}, AliasesVisible: true},
{Name: "command", Type: shortcut.FlagString, Desc: "更新动作;不能为空", Enum: []string{"append", "overwrite", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}},
{Name: "command", Type: shortcut.FlagString, Desc: "更新动作;不能为空", Enum: []string{"append", "overwrite", "block_insert_before", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}},
{Name: "content", Type: shortcut.FlagString, Desc: docRequiredContentInputDescription, Aliases: []string{"text"}, AliasesVisible: true},
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
{Name: "block-id", Type: shortcut.FlagString, Desc: "目标或源 block ID;相关动作要求时不能为空"},
{Name: "after-block-id", Type: shortcut.FlagString, Desc: "插入位置参考 block ID;相关动作要求时不能为空"},
{Name: "before-block-id", Type: shortcut.FlagString, Desc: "向前插入时的位置参考 block ID;block_insert_before 要求不能为空"},
{Name: "heading-level", Type: shortcut.FlagInt, Desc: "将插入内容写为指定级别标题(1-6);仅支持 Markdown block_insert_before/block_insert_after"},
{Name: "old", Type: shortcut.FlagString, Desc: "str_replace 原文字,不能为空"},
{Name: "new", Type: shortcut.FlagString, Desc: "str_replace 新文字;--old 不能为空,新值可为空但参数必须显式提供"},
{Name: "expected-revision", Type: shortcut.FlagInt, Desc: "仅 overwrite+jsonml:传给服务端执行原子 revision 条件写"},
},
Tips: []string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
Tips: []string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_insert_before --before-block-id <BLOCK_ID> --content "发布说明" --heading-level 1`},
Validate: func(rt *shortcut.RuntimeContext) error {
command := rt.Str("command")
if rt.StrFirst("node", "doc") == "" {
@@ -408,7 +419,7 @@ var Update = shortcut.Shortcut{
return apperrors.NewValidation("缺少 --command")
}
switch command {
case "append", "overwrite", "block_insert_after", "block_replace":
case "append", "overwrite", "block_insert_before", "block_insert_after", "block_replace":
if rt.StrFirst("content", "text") == "" {
return apperrors.NewValidation("该更新动作的 --content 不能为空")
}
@@ -425,6 +436,21 @@ var Update = shortcut.Shortcut{
return apperrors.NewValidation("该 block 操作必须提供 --after-block-id")
}
}
if command == "block_insert_before" && rt.Str("before-block-id") == "" {
return apperrors.NewValidation("--command block_insert_before 必须提供 --before-block-id")
}
if rt.Changed("heading-level") {
level := rt.Int("heading-level")
if command != "block_insert_before" && command != "block_insert_after" {
return apperrors.NewValidation("--heading-level 仅支持 block_insert_before/block_insert_after")
}
if rt.Str("doc-format") != "markdown" {
return apperrors.NewValidation("--heading-level 仅支持 --doc-format markdown")
}
if level < 1 || level > 6 {
return apperrors.NewValidation("--heading-level 必须在 1-6 之间")
}
}
if command == "str_replace" && (rt.Str("old") == "" || !rt.Changed("new")) {
return apperrors.NewValidation("--command str_replace 必须同时提供 --old 和 --new")
}
@@ -436,7 +462,7 @@ var Update = shortcut.Shortcut{
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "after-block-id", "old", "new"}, Description: "依 command 校验,所需文本或 block 参数不能为空"}},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "after-block-id", "before-block-id", "old", "new"}, Description: "依 command 校验,所需文本或 block 参数不能为空"}},
Execute: executeUpdate,
}
@@ -511,7 +537,8 @@ var CheckpointUpdate = shortcut.Shortcut{
append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
data := map[string]any{"nodeId": rt.Str("node"), "verified": true, "verification": verification}
verificationSummary := compactDocVerification(verification, content, rt.Str("mode"), "markdown", nil)
data := map[string]any{"nodeId": rt.Str("node"), "verified": true, "verification": verificationSummary}
if len(chunks) > 1 {
data["chunksWritten"] = len(chunks)
data["chunkPlan"] = chunkPlan.Summary()
@@ -582,13 +609,24 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
return err
}
if rt.Str("doc-format") == "jsonml" && content != "" {
content, err = validateJSONML(content)
switch command {
case "overwrite":
content, err = validateJSONMLBody(rt.Command(), content)
case "block_insert_before", "block_insert_after", "block_replace":
content, err = validateJSONMLNode(rt.Command(), content)
}
if err != nil {
return err
}
}
nodeID := rt.StrFirst("node", "doc")
plan := map[string]any{"nodeId": nodeID, "command": command, "blockId": rt.Str("block-id"), "afterBlockId": rt.Str("after-block-id"), "contentBytes": len(content)}
if beforeBlockID := rt.Str("before-block-id"); beforeBlockID != "" {
plan["beforeBlockId"] = beforeBlockID
}
if rt.Changed("heading-level") {
plan["headingLevel"] = rt.Int("heading-level")
}
if rt.Changed("expected-revision") {
plan["expectedRevision"] = rt.Int("expected-revision")
plan["optimisticCheck"] = "server_enforced"
@@ -613,19 +651,26 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
params["markdown"] = content
}
return executeVerifiedDocContentMutation(rt, params, node, content, command, rt.Str("doc-format"))
case "block_insert_after":
case "block_insert_before", "block_insert_after":
verificationFormat := blockVerificationFormat(rt.Str("doc-format"))
params := map[string]any{"nodeId": node, "referenceBlockId": rt.Str("after-block-id"), "where": "after"}
where := "after"
referenceBlockID := rt.Str("after-block-id")
if command == "block_insert_before" {
where = "before"
referenceBlockID = rt.Str("before-block-id")
}
params := map[string]any{"nodeId": node, "referenceBlockId": referenceBlockID, "where": where}
if rt.Str("doc-format") == "jsonml" {
params["format"], params["jsonml"] = "jsonml", content
} else if rt.Changed("heading-level") {
params["element"] = map[string]any{"blockType": "heading", "heading": map[string]any{"text": content, "level": strconv.Itoa(rt.Int("heading-level"))}}
} else {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
referenceBlockID := rt.Str("after-block-id")
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node,
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
func(result, data map[string]any) bool {
return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format"))
return verifyInsertedBlock(result, data, referenceBlockID, where, content, rt.Str("doc-format"), rt.Int("heading-level"))
})
case "block_replace":
blockID := rt.Str("block-id")
@@ -750,7 +795,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID,
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
func(result, data map[string]any) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown")
return verifyInsertedCanonicalBlockContent(result, data, referenceBlockID, expectedContent, "markdown")
})
}
@@ -778,11 +823,12 @@ func executeVerifiedDocMutation(
return docVerificationError(operation, "verify", nodeID, fmt.Errorf("回读结果未匹配预期变更"), append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
verificationSummary := compactDocVerification(verification, "", "", "", params)
return rt.Output(docEnvelope(operation, map[string]any{
"nodeId": nodeID,
"verified": true,
"result": result,
"verification": verification,
"verification": verificationSummary,
}, steps...))
}
@@ -836,8 +882,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
return docVerificationError("doc.update", "verify", nodeID, fmt.Errorf("回读结果未包含预期内容"), append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
verificationSummary := compactDocVerification(verification, content, mode, format, nil)
data := map[string]any{
"nodeId": nodeID, "mode": mode, "chunksWritten": len(chunks), "verified": true, "verification": verification,
"nodeId": nodeID, "mode": mode, "chunksWritten": len(chunks), "verified": true, "verification": verificationSummary,
}
if len(chunks) > 1 {
data["chunkPlan"] = chunkPlan.Summary()
@@ -845,6 +892,75 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
return rt.Output(withDocWarnings(docEnvelope("doc.update", data, steps...), chunkPlan.Warnings()))
}
const docVerificationExcerptRunes = 160
// compactDocVerification keeps the proof that a write was read back while
// avoiding a second copy of the full document or block collection in the
// Shortcut result. Full content remains available through doc +fetch.
func compactDocVerification(value map[string]any, expected, mode, format string, mutation map[string]any) map[string]any {
summary := map[string]any{"verified": true}
if expected != "" {
summary["kind"] = "content"
summary["format"] = format
summary["mode"] = mode
summary["expectedBytes"] = len(expected)
candidate := matchingDocumentContent(value, expected, mode, format)
if candidate != "" {
normalized := normalizeDocumentContentForVerification(candidate, format)
digest := sha256.Sum256([]byte(normalized))
summary["readbackBytes"] = len(candidate)
summary["readbackSha256"] = fmt.Sprintf("%x", digest[:])
summary["evidenceExcerpt"] = docVerificationExcerpt(candidate, mode, docVerificationExcerptRunes)
}
return summary
}
if blocks, ok := documentBlockEntries(value); ok {
summary["kind"] = "blocks"
summary["readbackBlockCount"] = len(blocks)
if blockID := nestedString(mutation, "blockId"); blockID != "" {
summary["targetBlockId"] = blockID
}
if referenceBlockID := nestedString(mutation, "referenceBlockId"); referenceBlockID != "" {
summary["referenceBlockId"] = referenceBlockID
}
return summary
}
summary["kind"] = "metadata"
for _, key := range []string{"nodeId", "folderId", "workspaceId", "name", "contentType", "revision"} {
if text := nestedString(value, key); text != "" {
summary[key] = text
}
}
if revision, ok := nestedNonNegativeInt(value, "revision"); ok {
summary["revision"] = revision
}
return summary
}
func matchingDocumentContent(value map[string]any, expected, mode, format string) string {
for _, candidate := range documentContentCandidates(value, format) {
if verifyUpdatedDocumentContent(map[string]any{"content": candidate}, expected, mode, format) {
return candidate
}
}
return ""
}
func docVerificationExcerpt(content, mode string, maxRunes int) string {
runes := []rune(strings.TrimSpace(content))
if maxRunes <= 0 || len(runes) <= maxRunes {
return string(runes)
}
if mode == "append" {
return "…" + string(runes[len(runes)-maxRunes:])
}
head := maxRunes / 2
tail := maxRunes - head
return string(runes[:head]) + "…" + string(runes[len(runes)-tail:])
}
func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) {
params := cloneMap(rawParams)
allBlocks, _ := params["__allBlocks"].(bool)
@@ -1071,12 +1187,22 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool
func markdownSemanticallyEquivalent(left, right string) bool {
leftFingerprint, leftOK := markdownSemanticFingerprint(left)
rightFingerprint, rightOK := markdownSemanticFingerprint(right)
if leftOK && rightOK && leftFingerprint == rightFingerprint {
return true
}
leftFingerprint, leftOK = markdownServiceSemanticFingerprint(left)
rightFingerprint, rightOK = markdownServiceSemanticFingerprint(right)
return leftOK && rightOK && leftFingerprint == rightFingerprint
}
func markdownSemanticallyEndsWith(content, suffix string) bool {
contentFingerprint, contentOK := markdownSemanticFingerprint(content)
suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix)
if contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint) {
return true
}
contentFingerprint, contentOK = markdownServiceSemanticFingerprint(content)
suffixFingerprint, suffixOK = markdownServiceSemanticFingerprint(suffix)
return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint)
}
@@ -1091,6 +1217,146 @@ func markdownSemanticFingerprint(source string) (string, bool) {
return rendered.String(), true
}
// markdownServiceSemanticFingerprint preserves Markdown structure and authored
// values while ignoring layout-only normalization performed by the document
// service, such as hard/soft line breaks, list tightness, and insignificant
// whitespace. Exact rendered HTML remains the first comparison path above.
func markdownServiceSemanticFingerprint(source string) (string, bool) {
if len(source) > docMarkdownVerifyMax {
return "", false
}
sourceBytes := []byte(normalizeDocInputLineEndings(source))
document := docMarkdown.Parser().Parse(goldmarktext.NewReader(sourceBytes))
builder := markdownFingerprintBuilder{}
// The callback never returns an error, so Walk cannot fail here.
_ = goldmarkast.Walk(document, func(node goldmarkast.Node, entering bool) (goldmarkast.WalkStatus, error) {
if node.Kind() == goldmarkast.KindDocument {
return goldmarkast.WalkContinue, nil
}
if !entering {
if markdownFingerprintIsLeaf(node) {
return goldmarkast.WalkContinue, nil
}
builder.token("close", markdownFingerprintNodeKind(node))
return goldmarkast.WalkContinue, nil
}
switch typed := node.(type) {
case *goldmarkast.Text:
builder.text(string(typed.Value(sourceBytes)))
return goldmarkast.WalkContinue, nil
case *goldmarkast.String:
builder.text(string(typed.Value))
return goldmarkast.WalkContinue, nil
case *goldmarkast.CodeSpan:
var value strings.Builder
for child := typed.FirstChild(); child != nil; child = child.NextSibling() {
if textNode, ok := child.(*goldmarkast.Text); ok {
value.Write(textNode.Value(sourceBytes))
}
}
builder.token("code_span", value.String())
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.CodeBlock:
builder.token("code_block", string(typed.Lines().Value(sourceBytes)))
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.FencedCodeBlock:
builder.token("fenced_code", string(typed.Language(sourceBytes))+"\x00"+string(typed.Lines().Value(sourceBytes)))
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.HTMLBlock:
value := append([]byte(nil), typed.Lines().Value(sourceBytes)...)
if typed.HasClosure() {
value = append(value, typed.ClosureLine.Value(sourceBytes)...)
}
builder.token("html_block", string(value))
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.RawHTML:
builder.token("raw_html", string(typed.Segments.Value(sourceBytes)))
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.AutoLink:
builder.token("auto_link", string(typed.URL(sourceBytes)))
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.LinkReferenceDefinition:
builder.token("link_reference", string(typed.Label)+"\x00"+string(typed.Destination)+"\x00"+string(typed.Title))
return goldmarkast.WalkSkipChildren, nil
case *goldmarkast.Heading:
builder.token("open", fmt.Sprintf("heading:%d", typed.Level))
case *goldmarkast.List:
builder.token("open", fmt.Sprintf("list:%t:%d", typed.IsOrdered(), typed.Start))
case *goldmarkast.Emphasis:
builder.token("open", fmt.Sprintf("emphasis:%d", typed.Level))
case *goldmarkast.Link:
builder.token("open", "link:"+string(typed.Destination)+"\x00"+string(typed.Title))
case *goldmarkast.Image:
builder.token("open", "image:"+string(typed.Destination)+"\x00"+string(typed.Title))
case *extensionast.Table:
alignments := make([]string, len(typed.Alignments))
for index, alignment := range typed.Alignments {
alignments[index] = alignment.String()
}
builder.token("open", "table:"+strings.Join(alignments, ","))
case *extensionast.TableCell:
builder.token("open", "table_cell:"+typed.Alignment.String())
default:
builder.token("open", markdownFingerprintNodeKind(node))
}
return goldmarkast.WalkContinue, nil
})
builder.flushText()
return builder.value.String(), true
}
type markdownFingerprintBuilder struct {
value strings.Builder
pendingText strings.Builder
}
func (builder *markdownFingerprintBuilder) text(value string) {
value = string(goldmarkutil.UnescapePunctuations([]byte(value)))
value = strings.Join(strings.Fields(value), " ")
if value == "" {
return
}
if builder.pendingText.Len() > 0 {
builder.pendingText.WriteByte(' ')
}
builder.pendingText.WriteString(value)
}
func (builder *markdownFingerprintBuilder) token(kind, value string) {
builder.flushText()
fmt.Fprintf(&builder.value, "%s:%d:%s;", kind, len(value), value)
}
func (builder *markdownFingerprintBuilder) flushText() {
if builder.pendingText.Len() == 0 {
return
}
value := builder.pendingText.String()
fmt.Fprintf(&builder.value, "text:%d:%s;", len(value), value)
builder.pendingText.Reset()
}
func markdownFingerprintIsLeaf(node goldmarkast.Node) bool {
switch node.(type) {
case *goldmarkast.Text, *goldmarkast.String, *goldmarkast.CodeSpan, *goldmarkast.CodeBlock,
*goldmarkast.FencedCodeBlock, *goldmarkast.HTMLBlock, *goldmarkast.RawHTML,
*goldmarkast.AutoLink, *goldmarkast.LinkReferenceDefinition:
return true
default:
return false
}
}
func markdownFingerprintNodeKind(node goldmarkast.Node) string {
switch node.(type) {
case *goldmarkast.Paragraph, *goldmarkast.TextBlock:
return "paragraph"
default:
return node.Kind().String()
}
}
func stripReadbackDocumentTitle(content string) string {
lines := strings.Split(content, "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") {
@@ -1103,8 +1369,8 @@ func stripReadbackDocumentTitle(content string) string {
return strings.Join(lines, "\n")
}
func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format)
func verifyInsertedBlock(result, data map[string]any, referenceBlockID, where, expected, format string, headingLevel int) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, where, normalizeDocumentContentForVerification(expected, format), format, headingLevel)
}
func blockVerificationFormat(format string) string {
@@ -1114,22 +1380,79 @@ func blockVerificationFormat(format string) string {
return "element"
}
func verifyInsertedCanonicalBlock(result, data map[string]any, referenceBlockID, expected, format string) bool {
if insertedID := nestedString(result, "blockId", "elementId", "id"); insertedID != "" {
if blockContentEquals(data, insertedID, expected, format) {
return true
}
}
func verifyInsertedCanonicalBlock(result, data map[string]any, referenceBlockID, where, expected, format string, headingLevel int) bool {
blocks := orderedCanonicalBlocks(data, format)
for index, block := range blocks {
if canonicalBlockIdentity(block, format) != referenceBlockID || index+1 >= len(blocks) {
for referenceIndex, block := range blocks {
if canonicalBlockIdentity(block, format) != referenceBlockID {
continue
}
return canonicalBlockContent(blocks[index+1], format) == expected
insertedIndex := referenceIndex + 1
if where == "before" {
insertedIndex = referenceIndex - 1
}
if insertedIndex < 0 || insertedIndex >= len(blocks) {
return false
}
inserted := blocks[insertedIndex]
if insertedID := nestedString(result, "blockId", "elementId", "id"); insertedID != "" && canonicalBlockIdentity(inserted, format) != insertedID {
return false
}
if canonicalBlockContent(inserted, format) != expected {
return false
}
return headingLevel == 0 || canonicalHeadingLevel(inserted) == headingLevel
}
return false
}
// Copy insertion keeps compatibility with servers that return only the newly
// inserted block in readback. Ordinary before/after insertion uses the stricter
// positional verifier above because placement is part of that command's result.
func verifyInsertedCanonicalBlockContent(result, data map[string]any, referenceBlockID, expected, format string) bool {
if insertedID := nestedString(result, "blockId", "elementId", "id"); insertedID != "" && blockContentEquals(data, insertedID, expected, format) {
return true
}
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, "after", expected, format, 0)
}
func canonicalHeadingLevel(value any) int {
block, ok := value.(map[string]any)
if !ok {
return 0
}
if element, ok := block["element"].(map[string]any); ok {
block = element
}
if blockType, _ := block["blockType"].(string); blockType != "" && blockType != "heading" {
return 0
}
heading, ok := block["heading"].(map[string]any)
if !ok {
return 0
}
switch level := heading["level"].(type) {
case int:
return level
case float64:
if level == float64(int(level)) {
return int(level)
}
case json.Number:
parsed, err := level.Int64()
if err == nil {
return int(parsed)
}
case string:
normalized := strings.TrimSpace(level)
normalized = strings.TrimPrefix(normalized, "heading-")
parsed, err := strconv.Atoi(normalized)
if err == nil {
return parsed
}
}
return 0
}
func blockContentEquals(data map[string]any, blockID, expected, format string) bool {
block := findCanonicalBlock(data, blockID, format)
if block == nil {
+41 -4
View File
@@ -5,6 +5,7 @@ package doc
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
@@ -14,6 +15,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
@@ -58,9 +60,12 @@ func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
}
})
}
if _, err := validateJSONML(`["",{}]`); err == nil {
if _, err := validateJSONMLNode(&cobra.Command{}, `["",{}]`); err == nil {
t.Fatal("empty JSONML tag succeeded")
}
if _, err := validateJSONMLNode(&cobra.Command{}, ""); err == nil {
t.Fatal("empty JSONML node reached shortcut validation")
}
validation := classifyDocWriteFailure(apperrors.NewValidation("bad"))
if validation.reason != "invalid_input" || validation.executionStarted {
@@ -137,11 +142,43 @@ func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" {
t.Fatal("element canonical traversal failed")
}
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") ||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") ||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") {
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "after", "element", 0) ||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "after", "element", 0) ||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "after", "element", 0) {
t.Fatal("inserted block verification branch contract failed")
}
if verifyInsertedCanonicalBlock(map[string]any{"blockId": "other"}, elementTree, "ref", "after", "after", "element", 0) {
t.Fatal("inserted block with mismatched result ID verified")
}
if verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "wrong", "element", 0) {
t.Fatal("inserted block with mismatched content verified")
}
if !verifyInsertedCanonicalBlockContent(map[string]any{}, elementTree, "ref", "after", "element") {
t.Fatal("copy insertion positional fallback failed")
}
for _, tc := range []struct {
name string
value any
want int
}{
{name: "non map", value: "heading", want: 0},
{name: "element wrapper", value: map[string]any{"element": map[string]any{"blockType": "heading", "heading": map[string]any{"level": "1"}}}, want: 1},
{name: "non heading", value: map[string]any{"blockType": "paragraph", "heading": map[string]any{"level": "1"}}, want: 0},
{name: "missing heading", value: map[string]any{"blockType": "heading"}, want: 0},
{name: "integer", value: map[string]any{"heading": map[string]any{"level": 1}}, want: 1},
{name: "integral float", value: map[string]any{"heading": map[string]any{"level": float64(2)}}, want: 2},
{name: "fractional float", value: map[string]any{"heading": map[string]any{"level": 2.5}}, want: 0},
{name: "valid json number", value: map[string]any{"heading": map[string]any{"level": json.Number("3")}}, want: 3},
{name: "invalid json number", value: map[string]any{"heading": map[string]any{"level": json.Number("bad")}}, want: 0},
{name: "invalid string", value: map[string]any{"heading": map[string]any{"level": "bad"}}, want: 0},
{name: "unsupported level", value: map[string]any{"heading": map[string]any{"level": true}}, want: 0},
} {
t.Run("heading level "+tc.name, func(t *testing.T) {
if got := canonicalHeadingLevel(tc.value); got != tc.want {
t.Fatalf("canonicalHeadingLevel(%#v) = %d, want %d", tc.value, got, tc.want)
}
})
}
if blockContentEquals(map[string]any{}, "missing", "after", "element") {
t.Fatal("missing block matched")
}
+53 -30
View File
@@ -37,8 +37,8 @@ var Search = shortcut.Shortcut{
Service: "doc",
Command: "+search",
Product: productDoc,
Description: "按关键词搜索有权限的文档 (不传则返回最近访问)",
Intent: "当你只记得文档的标题或主题词、需要先定位到某篇钉钉文档拿到它的 nodeId/URL 以便后续阅读或编辑时使用;可按关键词、扩展名、创建/访问时间、创建者等条件过滤,不传关键词则返回最近访问的文档,返回匹配的文档列表。",
Description: "按关键词或过滤条件搜索有权限的文档;默认只读取一页",
Intent: "当你需要按标题、主题词或过滤条件查找文档时使用;默认只读取一页。要求全部匹配、完整候选、判断唯一或不存在时必须使用 --page-all;只要前 N 条匹配结果时使用 --limit N。最近访问或最近编辑列表改用 drive +recent。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -52,24 +52,28 @@ var Search = shortcut.Shortcut{
CLIPath: "doc +search",
PrimaryCLIPath: "doc +search",
},
Description: "按关键词搜索有权限的文档 (不传则返回最近访问)",
Description: "按关键词或过滤条件搜索有权限的文档;默认只读取一页",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按关键词搜索有权限的文档 (不传则返回最近访问)",
UseWhen: []string{"当你只记得文档的标题或主题词、需要先定位到某篇钉钉文档拿到它的 nodeId/URL 以便后续阅读或编辑时使用;可按关键词、扩展名、创建/访问时间、创建者等条件过滤,不传关键词则返回最近访问的文档,返回匹配的文档列表。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
AgentSummary: "按关键词或过滤条件搜索有权限的文档;默认只读取一页",
UseWhen: []string{"当你需要按标题、主题词或过滤条件查找文档时使用;默认只读取一页。要求全部匹配、完整候选、判断唯一或不存在时必须使用 --page-all;只要前 N 条匹配结果时使用 --limit N。最近访问或最近编辑列表改用 drive +recent。"},
AvoidWhen: []string{
"已经取得稳定 nodeId/URL 时直接使用目标读取或写入命令,不要再次按标题搜索",
"最近访问或最近编辑列表使用 drive +recent,不要用无关键词搜索替代",
"只读取前 N 条匹配结果时不要为了 Top-N 无条件翻完整个数据源",
},
Examples: []string{
"dws doc +search --query \"会议纪要\"",
"dws doc +search --extensions pdf,docx",
"dws doc +search --query \"会议纪要\" --page-all --max-pages 20",
"dws doc +search --query \"周报\" --limit 10",
},
},
},
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词,不传返回最近访问的文档"},
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词;不传仍兼容返回默认结果页,最近访问/编辑应使用 drive +recent"},
{Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "按文件扩展名过滤 (如 adoc,axls,pdf)"},
{Name: "created-from", Type: shortcut.FlagInt, Desc: "创建时间起始 (毫秒时间戳)"},
{Name: "created-to", Type: shortcut.FlagInt, Desc: "创建时间截止 (毫秒时间戳)"},
@@ -81,11 +85,13 @@ var Search = shortcut.Shortcut{
{Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页;--max-pages/--max-items 仅在 --page-all 时生效且必须大于 0"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--max-pages 仅在 --page-all 时生效,且必须大于 0"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "--page-all 最多返回文档数,必须大于 0"},
},
Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`},
Constraints: docAutoPaginationConstraints(),
Tips: []string{`dws doc +search --query "会议纪要" --page-all --max-pages 20`, `dws doc +search --query "周报" --limit 10`},
Validate: validateDocAutoPagination,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if v := rt.Str("query"); v != "" {
@@ -247,11 +253,13 @@ var List = shortcut.Shortcut{
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页;--max-pages/--max-items 仅在 --page-all 时生效且必须大于 0"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--max-pages 仅在 --page-all 时生效,且必须大于 0"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "--page-all 最多返回节点数,必须大于 0"},
},
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
Constraints: docAutoPaginationConstraints(),
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
Validate: validateDocAutoPagination,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Changed("folder") {
@@ -848,8 +856,8 @@ var TemplateList = shortcut.Shortcut{
Service: "doc",
Command: "+template-list",
Product: productDoc,
Description: "获取文档模板列表",
Intent: "当你想基于模板新建文档、需要先浏览可用的模板(自己的 MY 或公共 PUBLIC)并拿到 templateId 时使用;返回模板列表,随后可配合 +template-apply 套用。",
Description: "浏览可用文档模板;默认只读取一页",
Intent: "当你没有明确模板名称或关键词、需要浏览自己的 MY 或公共 PUBLIC 模板并获取 templateId 时使用;默认只读取一页,要求全部模板或完整浏览时必须使用 --page-all。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -863,37 +871,52 @@ var TemplateList = shortcut.Shortcut{
CLIPath: "doc +template-list",
PrimaryCLIPath: "doc +template-list",
},
Description: "获取文档模板列表",
Description: "浏览可用文档模板;默认只读取一页",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "获取文档模板列表",
UseWhen: []string{"当你想基于模板新建文档、需要先浏览可用的模板(自己的 MY 或公共 PUBLIC)并拿到 templateId 时使用;返回模板列表,随后可配合 +template-apply 套用。"},
AgentSummary: "浏览 MY/PUBLIC 文档模板;完整浏览必须显式自动翻页",
UseWhen: []string{"没有明确模板名称或关键词、需要浏览自己的或公开模板时使用;要求全部模板或完整模板库时使用 --page-all"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws doc +template-list --source PUBLIC"},
Examples: []string{
"dws doc +template-list --source PUBLIC",
"dws doc +template-list --source PUBLIC --page-all --max-pages 20",
},
},
},
Flags: []shortcut.Flag{
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源: MY / PUBLIC (默认 MY)", Enum: []string{"MY", "PUBLIC"}},
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回数量上限"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量(默认 20)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页;--max-pages/--max-items 仅在 --page-all 时生效且必须大于 0"},
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--max-pages 仅在 --page-all 时生效,且必须大于 0"},
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "--page-all 最多返回模板数,必须大于 0"},
},
Tips: []string{`dws doc +template-list --source PUBLIC`},
Constraints: docAutoPaginationConstraints(),
Tips: []string{`dws doc +template-list --source PUBLIC`, `dws doc +template-list --source PUBLIC --page-all --max-pages 20`},
Validate: validateDocAutoPagination,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if v := rt.Str("source"); v != "" {
params["templateSource"] = v
}
if rt.Changed("limit") {
params["maxResults"] = rt.Int("limit")
pageSize := rt.Int("limit")
if pageSize <= 0 {
pageSize = 20
}
if v := rt.Str("cursor"); v != "" {
params["nextCursor"] = v
result, err := collectDocPages(rt, "list_doc_templates", "templates", params, func(data map[string]any) []map[string]any {
return collectTemplateCandidates(data)
}, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
PageSizeParam: "maxResults", CursorParam: "nextCursor",
})
if err != nil {
return err
}
return rt.CallMCP("list_doc_templates", params)
return rt.Output(result)
},
}
@@ -4,11 +4,13 @@
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"time"
@@ -17,6 +19,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) {
@@ -39,7 +43,74 @@ func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) {
}
}
func TestCrossPlatformCoverageCompactDocVerificationKeepsBoundedContentEvidence(t *testing.T) {
expected := "新增结论:本周发布完成"
readback := strings.Repeat("历史正文\n", 2000) + expected
summary := compactDocVerification(map[string]any{"markdown": readback}, expected, "append", "markdown", nil)
if summary["verified"] != true || summary["kind"] != "content" || summary["mode"] != "append" {
t.Fatalf("summary = %#v", summary)
}
if summary["readbackBytes"] != len(readback) || summary["readbackSha256"] == "" {
t.Fatalf("summary evidence = %#v", summary)
}
excerpt, _ := summary["evidenceExcerpt"].(string)
if !strings.Contains(excerpt, expected) || len([]rune(excerpt)) > docVerificationExcerptRunes+1 {
t.Fatalf("excerpt = %q", excerpt)
}
encoded, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
if len(encoded) > 800 || bytes.Contains(encoded, []byte(strings.Repeat("历史正文", 20))) {
t.Fatalf("verification summary is not compact: %d bytes", len(encoded))
}
}
func TestCompactDocVerificationSummarizesBlockReadback(t *testing.T) {
summary := compactDocVerification(map[string]any{
"blocks": []any{
map[string]any{"blockId": "block-1", "paragraph": map[string]any{"text": strings.Repeat("a", 2000)}},
map[string]any{"blockId": "block-2", "paragraph": map[string]any{"text": strings.Repeat("b", 2000)}},
},
}, "", "", "", map[string]any{"blockId": "block-1"})
if summary["verified"] != true || summary["kind"] != "blocks" || summary["readbackBlockCount"] != 2 || summary["targetBlockId"] != "block-1" {
t.Fatalf("summary = %#v", summary)
}
encoded, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
if len(encoded) > 300 {
t.Fatalf("block verification summary is not compact: %s", encoded)
}
}
func TestCrossPlatformCoverageDocVerificationMetadataAndMissingContent(t *testing.T) {
summary := compactDocVerification(map[string]any{
"nodeId": "node-1", "folderId": "folder-1", "workspaceId": "space-1",
"name": "report", "contentType": "ALIDOC", "revision": 3.0,
}, "", "", "", nil)
if summary["kind"] != "metadata" || summary["nodeId"] != "node-1" || summary["revision"] != 3 {
t.Fatalf("metadata summary = %#v", summary)
}
if got := matchingDocumentContent(map[string]any{"markdown": "old"}, "new", "overwrite", "markdown"); got != "" {
t.Fatalf("unexpected matching content = %q", got)
}
}
func TestMarkdownServiceEscapedNumericLabelAndSoftBreakAreEquivalent(t *testing.T) {
expected := "# 文学分析要点\n\n**1. 五幕结构**\n正文内容。\n\n**2. 核心冲突**\n- 条目一\n- 条目二\n"
server := "# 文学分析要点\n\n**1\\. 五幕结构** 正文内容。\n\n**2\\. 核心冲突**\n- 条目一\n- 条目二\n"
if verifyUpdatedDocumentContent(map[string]any{"markdown": server}, expected, "overwrite", "markdown") {
return
}
expectedFingerprint, _ := markdownServiceSemanticFingerprint(expected)
serverFingerprint, _ := markdownServiceSemanticFingerprint(server)
t.Fatalf("escaped numeric label and soft break failed semantic verification:\nexpected: %s\nserver: %s", expectedFingerprint, serverFingerprint)
}
func TestCrossPlatformCoverageDocReadbackStopsOnCancellation(t *testing.T) {
//lint:ignore SA1012 This regression verifies the documented nil-context fallback.
if err := waitForDocVerification(nil, time.Nanosecond); err != nil {
t.Fatalf("completed verification wait = %v", err)
}
@@ -178,6 +249,59 @@ func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) {
}
}
func TestCrossPlatformCoverageMarkdownServiceLineBreakNormalization(t *testing.T) {
input := strings.Join([]string{
"### 一、五幕结构",
"",
"**第一幕:宿命相遇** ",
"维罗纳街头爆发冲突。",
"",
"1. **家族世仇 vs. 个体爱情** ",
" 旧秩序压制青年自由恋爱。",
"",
"2. **命运偶然 vs. 人为选择** ",
" 偶然事件与冲动选择共同造成悲剧。",
}, "\n")
server := strings.Join([]string{
"### 一、五幕结构",
"",
"**第一幕:宿命相遇** 维罗纳街头爆发冲突。",
"",
"1. **家族世仇 vs. 个体爱情** 旧秩序压制青年自由恋爱。",
"2. **命运偶然 vs. 人为选择** 偶然事件与冲动选择共同造成悲剧。",
}, "\n")
if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") {
inputFingerprint, _ := markdownServiceSemanticFingerprint(input)
serverFingerprint, _ := markdownServiceSemanticFingerprint(server)
t.Fatalf("service line-break and list-tightness normalization failed verification:\ninput: %s\nserver: %s", inputFingerprint, serverFingerprint)
}
missingListItem := strings.Replace(server, "2. **命运偶然 vs. 人为选择** 偶然事件与冲动选择共同造成悲剧。", "", 1)
if verifyUpdatedDocumentContent(map[string]any{"markdown": missingListItem}, input, "overwrite", "markdown") {
t.Fatal("missing list item passed semantic verification")
}
changedText := strings.Replace(server, "共同造成悲剧", "不会造成悲剧", 1)
if verifyUpdatedDocumentContent(map[string]any{"markdown": changedText}, input, "overwrite", "markdown") {
t.Fatal("changed document text passed semantic verification")
}
}
func TestCrossPlatformCoverageDocContentInputNormalizesLineEndings(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.md", []byte("first\r\nsecond\rthird"), 0o600); err != nil {
t.Fatal(err)
}
want := "first\nsecond\nthird"
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": want}},
}}
if err := runDocCoverage(t, Create, caller, "--name", "normalized", "--content", "@body.md"); err != nil {
t.Fatal(err)
}
if got := caller.history[0].params["markdown"]; got != want {
t.Fatalf("create markdown = %#v, want normalized line endings %#v", got, want)
}
}
func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) {
for _, test := range []struct {
name string
@@ -187,6 +311,7 @@ func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing
{name: "emphasis", left: `*important*`, right: `\*important\*`},
{name: "inline code", left: "`sales_data`", right: "`sales\\_data`"},
{name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"},
{name: "link destination", left: "[source](https://example.com/a)", right: "[source](https://example.com/b)"},
{name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"},
{name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"},
{name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"},
@@ -201,12 +326,34 @@ func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing
if _, ok := markdownSemanticFingerprint(oversized); ok {
t.Fatal("oversized Markdown entered semantic verification")
}
if _, ok := markdownServiceSemanticFingerprint(oversized); ok {
t.Fatal("oversized Markdown entered service semantic verification")
}
testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") })
if _, ok := markdownSemanticFingerprint("body"); ok {
t.Fatal("failed Markdown render produced a semantic fingerprint")
}
}
func TestCrossPlatformCoverageMarkdownServiceFingerprintNodeKinds(t *testing.T) {
for _, source := range []string{
" indented code\n",
"<script>\nalert('x')\n</script>\n",
"before <span>inline</span> after\n",
"<https://example.com/path>\n",
"[ref]: https://example.com/path \"title\"\n\n[link][ref]\n",
"![alt](https://example.com/image.png \"title\")\n",
} {
if fingerprint, ok := markdownServiceSemanticFingerprint(source); !ok || fingerprint == "" {
t.Fatalf("fingerprint failed for %q: %q/%v", source, fingerprint, ok)
}
}
testseam.Swap(t, &docMarkdown, goldmark.New(goldmark.WithExtensions(extension.Typographer)))
if fingerprint, ok := markdownServiceSemanticFingerprint("before -- after"); !ok || fingerprint == "" {
t.Fatalf("typographer string fingerprint = %q/%v", fingerprint, ok)
}
}
func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) {
wrapper := map[string]any{
"blockType": "paragraph",
+99 -4
View File
@@ -562,6 +562,7 @@ func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *te
response string
}{
{name: "insert", command: "block_insert_after", blockArg: "--after-block-id", response: `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"id-1"},"after"]]`},
{name: "insert before", command: "block_insert_before", blockArg: "--before-block-id", response: `["root",{},["p",{"uuid":"id-1"},"after"],["p",{"uuid":"ref"},"before"]]`},
{name: "replace", command: "block_replace", blockArg: "--block-id", response: `["root",{},["p",{"uuid":"target"},"after"]]`},
} {
t.Run(test.name, func(t *testing.T) {
@@ -585,6 +586,86 @@ func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *te
}
}
func TestCrossPlatformCoverageDocUpdateInsertsHeadingBeforeReference(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
const title = "发布说明 v1.0"
readback := func(level any, before bool) map[string]any {
heading := map[string]any{"id": "new", "blockType": "heading", "heading": map[string]any{"text": title, "level": level}}
reference := map[string]any{"id": "ref", "blockType": "paragraph", "paragraph": map[string]any{"text": "原标题"}}
blocks := []any{heading, reference}
if !before {
blocks = []any{reference, heading}
}
return map[string]any{"blocks": blocks, "hasMore": false}
}
t.Run("success", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {readback("heading-1", true)},
}}
if err := runDocCoverage(t, Update, caller,
"--node", "n", "--command", "block_insert_before", "--before-block-id", "ref",
"--content", title, "--heading-level", "1", "--yes"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].tool != "insert_document_block" || caller.history[1].tool != "list_document_blocks" {
t.Fatalf("calls = %#v", caller.history)
}
params := caller.history[0].params
if params["referenceBlockId"] != "ref" || params["where"] != "before" {
t.Fatalf("placement params = %#v", params)
}
element, _ := params["element"].(map[string]any)
heading, _ := element["heading"].(map[string]any)
if element["blockType"] != "heading" || heading["text"] != title || heading["level"] != "1" {
t.Fatalf("heading element = %#v", element)
}
})
t.Run("after success", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {readback("heading-1", false)},
}}
if err := runDocCoverage(t, Update, caller,
"--node", "n", "--command", "block_insert_after", "--after-block-id", "ref",
"--content", title, "--heading-level", "1", "--yes"); err != nil {
t.Fatal(err)
}
if caller.history[0].params["where"] != "after" {
t.Fatalf("placement params = %#v", caller.history[0].params)
}
element, _ := caller.history[0].params["element"].(map[string]any)
heading, _ := element["heading"].(map[string]any)
if heading["level"] != "1" {
t.Fatalf("heading level wire value = %#v, want string %q", heading["level"], "1")
}
})
for _, test := range []struct {
name string
readback map[string]any
}{
{name: "wrong position", readback: readback("heading-1", false)},
{name: "wrong heading level", readback: readback("heading-2", true)},
} {
t.Run(test.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"insert_document_block": {{"blockId": "new"}},
"list_document_blocks": {test.readback},
}}
err := runDocCoverage(t, Update, caller,
"--node", "n", "--command", "block_insert_before", "--before-block-id", "ref",
"--content", title, "--heading-level", "1", "--yes")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" {
t.Fatalf("error = %#v, want readback verification failure", err)
}
})
}
}
func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testing.T) {
t.Run("target on second page", func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
@@ -1055,6 +1136,7 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
{"update insert heading before", Update, []string{"--node", "n", "--command", "block_insert_before", "--before-block-id", "b", "--content", "x", "--heading-level", "1", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
@@ -1091,6 +1173,8 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
case "update insert text":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "x"}}}}
case "update insert heading before":
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "id-1", "blockType": "heading", "heading": map[string]any{"text": "x", "level": 1}}, map[string]any{"id": "b", "text": "reference"}}}}
case "update insert jsonml":
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"reference"],["p",{"uuid":"id-1"},"x"]]`}}
case "update replace text":
@@ -1147,7 +1231,7 @@ func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) {
if !flags["node"].Required || flags["command"].Required {
t.Fatalf("unconditional required flags: node=%v command=%v", flags["node"].Required, flags["command"].Required)
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
for _, name := range []string{"content", "block-id", "after-block-id", "before-block-id", "heading-level", "old", "new"} {
if got := flags[name].RequiredWhen; got != "" {
t.Errorf("--%s RequiredWhen = %q, want compatibility-safe custom constraint", name, got)
}
@@ -1171,6 +1255,11 @@ func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) {
}{
{name: "missing command", args: []string{"--node", "n"}, want: "--command"},
{name: "insert missing reference", args: []string{"--node", "n", "--command", "block_insert_after", "--content", "x"}, want: "--after-block-id"},
{name: "insert before missing reference", args: []string{"--node", "n", "--command", "block_insert_before", "--content", "x"}, want: "--before-block-id"},
{name: "heading on non-insert", args: []string{"--node", "n", "--command", "overwrite", "--content", "x", "--heading-level", "1"}, want: "仅支持 block_insert_before/block_insert_after"},
{name: "heading with jsonml", args: []string{"--node", "n", "--command", "block_insert_before", "--before-block-id", "b", "--content", `["h1",{},"x"]`, "--doc-format", "jsonml", "--heading-level", "1"}, want: "仅支持 --doc-format markdown"},
{name: "heading level too low", args: []string{"--node", "n", "--command", "block_insert_before", "--before-block-id", "b", "--content", "x", "--heading-level", "0"}, want: "必须在 1-6 之间"},
{name: "heading level too high", args: []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--heading-level", "7"}, want: "必须在 1-6 之间"},
{name: "copy missing reference", args: []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "b"}, want: "--after-block-id"},
{name: "jsonml append", args: []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml"}, want: "JSONML 当前不支持 append"},
{name: "revision without server CAS path", args: []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1"}, want: "仅支持 --command overwrite --doc-format jsonml"},
@@ -1270,15 +1359,21 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
} {
_, _ = nestedRevision(value)
}
if _, err := validateJSONML(`[`); err == nil {
if _, err := validateJSONMLBody(&cobra.Command{}, `[`); err == nil {
t.Fatal("invalid jsonml succeeded")
}
if _, err := validateJSONML(`{}`); err == nil {
if _, err := validateJSONMLBody(&cobra.Command{}, `{}`); err == nil {
t.Fatal("object jsonml succeeded")
}
if _, err := validateJSONML(`[["p",{},"x"]]`); err == nil {
if _, err := validateJSONMLNode(&cobra.Command{}, `[["p",{},"x"]]`); err == nil {
t.Fatal("nested element-array jsonml succeeded")
}
if _, err := validateJSONMLBody(&cobra.Command{}, `["p",{}]`); err == nil || !strings.Contains(err.Error(), `"root"`) {
t.Fatalf("non-root document jsonml error = %v", err)
}
if _, err := validateJSONMLNode(&cobra.Command{}, `["p",{}]`); err != nil {
t.Fatalf("single block jsonml failed: %v", err)
}
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
t.Fatal("nestedMap did not unwrap")
}
@@ -66,8 +66,8 @@ func canonicalizeHistoryShortcuts() {
compatHistoryList = compatibilityHistoryShortcut(VersionList, "+history-list", "+version-list")
compatHistoryRevert = compatibilityHistoryShortcut(VersionRevert, "+history-revert", "+version-revert")
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用。"
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板;默认只读取一页"
TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用;要求全部模板或完整模板库时必须使用 --page-all。"
TemplateList.Contract = templateListContract()
TemplateSearch.Description = "按名称或关键词检索文档模板"
TemplateSearch.Intent = "当用户已提供明确模板名称或关键词时使用;返回结构化候选和 resolved/not_found/selection_required 状态,零命中或多候选时停止创建。"
@@ -128,10 +128,14 @@ func templateSearchContract() corecmd.ContractDecl {
}
func templateListContract() corecmd.ContractDecl {
decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{
`dws doc +template-list --source PUBLIC`,
`dws doc +template-list --source PUBLIC --page-all --max-pages 20`,
})
decl.Selection.AvoidWhen = []string{
"已经有明确模板名称或关键词时使用 doc +template-search --query",
"已经拿到 templateId 且要创建文档时使用 doc +create-from-template --template-id",
"只需要当前页或明确 Top-N 时不要无条件读取完整模板库",
}
return decl
}
+55 -9
View File
@@ -12,11 +12,37 @@ import (
)
type docPageOptions struct {
PageAll bool
PageSize int
MaxPages int
MaxItems int
Cursor string
PageAll bool
PageSize int
MaxPages int
MaxItems int
Cursor string
PageSizeParam string
CursorParam string
}
func validateDocAutoPagination(rt *shortcut.RuntimeContext) error {
if !rt.Bool("page-all") {
if rt.Changed("max-pages") || rt.Changed("max-items") {
return fmt.Errorf("--max-pages/--max-items 仅与 --page-all 一起使用")
}
return nil
}
if rt.Int("max-pages") <= 0 {
return fmt.Errorf("--max-pages 必须大于 0")
}
if rt.Int("max-items") <= 0 {
return fmt.Errorf("--max-items 必须大于 0")
}
return nil
}
func docAutoPaginationConstraints() []shortcut.Constraint {
return []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
Flags: []string{"page-all", "max-pages", "max-items"},
Description: "--max-pages/--max-items 仅与 --page-all 一起使用,且必须大于 0",
}}
}
func collectDocPages(
@@ -35,6 +61,12 @@ func collectDocPages(
if options.MaxItems <= 0 {
options.MaxItems = 500
}
if strings.TrimSpace(options.PageSizeParam) == "" {
options.PageSizeParam = "pageSize"
}
if strings.TrimSpace(options.CursorParam) == "" {
options.CursorParam = "pageToken"
}
pageLimit := 1
if options.PageAll {
pageLimit = options.MaxPages
@@ -61,9 +93,9 @@ func collectDocPages(
requestPageSize = remaining
}
params := cloneMap(base)
params["pageSize"] = requestPageSize
params[options.PageSizeParam] = requestPageSize
if cursor != "" {
params["pageToken"] = cursor
params[options.CursorParam] = cursor
}
data, err := rt.CallMCPData(productDoc, tool, params)
if err != nil {
@@ -106,6 +138,9 @@ func collectDocPages(
hasMore = true
stopReason = "pagination_unproven"
}
if complete {
stopReason = "source_complete"
}
if len(items) >= options.MaxItems && hasMore {
truncated = true
stopReason = "max_items"
@@ -115,6 +150,9 @@ func collectDocPages(
hasMore = true
}
if !options.PageAll && !complete && stopReason == "" {
stopReason = "single_page"
}
if truncated || complete || !options.PageAll {
break
}
@@ -132,6 +170,9 @@ func collectDocPages(
if page == pageLimit {
truncated = true
stopReason = "max_pages"
complete = false
hasMore = true
break
}
}
@@ -146,7 +187,6 @@ func collectDocPages(
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"failures": []map[string]any{},
}
return result, nil
}
@@ -165,11 +205,17 @@ func docPaginationError(tool, reason string, cause error, page int, items []map[
"contractVersion": "doc.list.v1",
"status": "partial_success",
"complete": false,
"truncated": true,
"hasMore": true,
"reason": reason,
"stopReason": reason,
"page": page,
"nextCursor": cursor,
"count": len(items),
"items": items,
"failures": []map[string]any{{
"page": page, "cursor": cursor, "reason": reason,
}},
}),
apperrors.WithCause(cause),
)
@@ -184,7 +230,7 @@ func cloneMap(source map[string]any) map[string]any {
}
func pageItemKey(item map[string]any) string {
for _, key := range []string{"nodeId", "id", "url"} {
for _, key := range []string{"nodeId", "templateId", "id", "url"} {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
return key + ":" + strings.TrimSpace(value)
}
+123 -3
View File
@@ -26,6 +26,112 @@ func TestCrossPlatformCoverageDocSearchPageAllContract(t *testing.T) {
}
}
func TestCrossPlatformCoverageDocSearchPublishesExplicitStopReasons(t *testing.T) {
for _, tc := range []struct {
name string
response map[string]any
wantReason string
complete bool
}{
{
name: "single page with continuation",
response: map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}, "hasMore": true, "nextPageToken": "p2"},
wantReason: "single_page",
},
{
name: "source exhausted",
response: map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}, "hasMore": false},
wantReason: "source_complete",
complete: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {tc.response}}}
var captured map[string]any
declaration := Search
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
var err error
captured, err = collectDocPages(rt, "search_documents", "documents", nil, searchDocsProject, docPageOptions{PageSize: 10})
return err
}
if err := runDocCoverage(t, declaration, caller); err != nil {
t.Fatal(err)
}
if captured["stopReason"] != tc.wantReason || captured["complete"] != tc.complete {
t.Fatalf("pagination ledger = %#v", captured)
}
if captured["pagesRead"] != 1 {
t.Fatalf("page counters = %#v", captured)
}
for _, redundant := range []string{"pagesFetched", "paginationKnown", "truncatedByPageLimit", "truncatedByResultLimit", "resumeCursorReliable", "failures"} {
if _, exists := captured[redundant]; exists {
t.Fatalf("redundant field %q remains in result: %#v", redundant, captured)
}
}
})
}
}
func TestCrossPlatformCoverageDocTemplateListPageAll(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_doc_templates": {
{"templates": []any{map[string]any{"templateId": "a", "templateName": "A"}}, "hasMore": true, "nextCursor": "p2"},
{"templates": []any{map[string]any{"templateId": "a", "templateName": "A"}, map[string]any{"templateId": "b", "templateName": "B"}}, "hasMore": false},
},
}}
if err := runDocCoverage(t, TemplateList, caller, "--source", "PUBLIC", "--page-all", "--limit", "2"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[0].params["maxResults"] != 2 || caller.history[1].params["nextCursor"] != "p2" {
t.Fatalf("template pagination calls = %#v", caller.history)
}
if _, exists := caller.history[0].params["pageSize"]; exists {
t.Fatalf("template list used doc search pageSize: %#v", caller.history[0].params)
}
}
func TestCrossPlatformCoverageDocTemplateListDefaultsAndFailure(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_doc_templates": {{"templates": []any{}, "hasMore": false}},
}}
if err := runDocCoverage(t, TemplateList, caller, "--source", "PUBLIC"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 1 || caller.history[0].params["maxResults"] != 20 {
t.Fatalf("template defaults = %#v", caller.history)
}
failure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, TemplateList, failure, "--source", "PUBLIC"); err == nil {
t.Fatal("template-list MCP failure succeeded")
}
}
func TestCrossPlatformCoverageDocPaginationControlsRequirePageAll(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--max-items", "1"); err == nil {
t.Fatal("--max-items without --page-all succeeded")
}
if len(caller.history) != 0 {
t.Fatalf("invalid pagination reached MCP: %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocPaginationRejectsNonPositiveBounds(t *testing.T) {
for _, args := range [][]string{
{"--query", "report", "--page-all", "--max-pages", "0"},
{"--query", "report", "--page-all", "--max-items", "0"},
} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, Search, caller, args...); err == nil {
t.Fatalf("invalid pagination bounds %v succeeded", args)
}
if len(caller.history) != 0 {
t.Fatalf("invalid pagination bounds %v reached MCP: %#v", args, caller.history)
}
}
}
func TestCrossPlatformCoverageDocPaginationFailsClosedOnStalledCursor(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_nodes": {
@@ -46,12 +152,24 @@ func TestCrossPlatformCoverageDocPaginationMaxItemsStopsAtPageBoundary(t *testin
{"documents": []any{map[string]any{"nodeId": "c"}}, "hasMore": true, "nextPageToken": "p3"},
},
}}
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil {
var result map[string]any
declaration := Search
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
var err error
result, err = collectDocPages(rt, "search_documents", "documents", nil, searchDocsProject, docPageOptions{
PageAll: rt.Bool("page-all"), PageSize: rt.Int("limit"), MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"),
})
return err
}
if err := runDocCoverage(t, declaration, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil {
t.Fatal(err)
}
if len(caller.history) != 2 || caller.history[1].params["pageSize"] != 1 || caller.history[1].params["pageToken"] != "p2" {
t.Fatalf("max-items pagination calls = %#v", caller.history)
}
if result["count"] != 3 || result["complete"] != false || result["truncated"] != true || result["stopReason"] != "max_items" || result["nextCursor"] != "p3" {
t.Fatalf("max-items result = %#v", result)
}
}
func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.T) {
@@ -74,16 +192,18 @@ func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.
func TestCrossPlatformCoverageDocPaginationFinalBranchMatrix(t *testing.T) {
run := func(t *testing.T, caller *docCoverageCaller, options docPageOptions) (map[string]any, error) {
t.Helper()
var result map[string]any
declaration := Search
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
result, err := collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options)
var err error
result, err = collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options)
if err == nil {
err = rt.Output(result)
}
return err
}
err := runDocCoverage(t, declaration, caller)
return nil, err
return result, err
}
defaults := &docCoverageCaller{responses: map[string][]map[string]any{
+5 -3
View File
@@ -14,7 +14,9 @@ runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
chat_target_bytes=10000
chat_max_overage_percent=10
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
doc_max_bytes=10000
doc_target_bytes=10000
doc_max_overage_percent=2
doc_max_bytes=$((doc_target_bytes * (100 + doc_max_overage_percent) / 100))
event_max_bytes=10000
runtime_contract_max_bytes=3000
@@ -28,7 +30,7 @@ fi
doc_bytes="$(wc -c < "$doc_skill" | tr -d ' ')"
if [ "$doc_bytes" -gt "$doc_max_bytes" ]; then
printf '%s\n' \
"skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (max ${doc_max_bytes})" >&2
"skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (target ${doc_target_bytes}, max ${doc_max_bytes} with ${doc_max_overage_percent}% allowance)" >&2
exit 1
fi
@@ -193,4 +195,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
fi
printf '%s\n' \
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_max=$doc_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows doc_shortcut_rows=$doc_shortcut_rows)"
"skill context budget: ok (chat_bytes=$chat_bytes chat_target=$chat_target_bytes chat_max=$chat_max_bytes chat_allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_target=$doc_target_bytes doc_max=$doc_max_bytes doc_allowance=${doc_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows doc_shortcut_rows=$doc_shortcut_rows)"
+5 -5
View File
@@ -40,17 +40,17 @@ metadata:
| 用户意图 | 唯一推荐入口 | 关键边界 |
|---|---|---|
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` |
| 最近访问或最近编辑文档 | 加载 `dingtalk-drive`,执行 `dws drive +recent [--operate-type 1] --limit <N>` | 默认最近访问,`1` 为最近编辑;不要用 `doc +search` 替代最近列表 |
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` |
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 仅打开任务所需的 `--include-*`,不要默认全取 |
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 基础元信息默认返回;仅样式、权限、历史、媒体、评论使用对应 `--include-*`,不存在 `--include-info` |
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 |
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | 唯一文本直接 `str_replace`;章节/block 先局部取 ID 再精准修改;整篇覆盖才用 overwrite |
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` |
| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | 在线改、协作编辑或转换用 import;未指定 folder/workspace 时解析“我的文档”目标并回读验证落点;仅保留原文件切 `dingtalk-drive` |
| 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog |
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 |
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC] [--page-all]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”加 `--page-all` 并检查 `complete` |
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 来源可选 MY/PUBLIC;零命中停止,禁止拿无关模板替代;多候选消歧 |
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
@@ -65,7 +65,7 @@ metadata:
- 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。
- 仅在结果明确且关键内容回读匹配后报告写入完成。
- 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。
- `+import` 已含上传、转换和轮询;成功检查 `success=true`、`taskId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。
- `+import` 已含上传、转换、轮询和落点回读;成功检查 `success=true`、`verified=true`、`taskId`、`nodeId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
## 参数与安全边界
@@ -4,13 +4,14 @@
普通追加、覆盖和 block 编辑统一使用 `+update`:
`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--after-block-id`。
`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--before-block-id/--after-block-id`。
```bash
dws doc +update --node <DOC_ID> --command append --content "补充说明" --format json
dws doc +update --node <DOC_ID> --command append --content @append.md --format json
dws doc +update --node <DOC_ID> --command overwrite --content @full.md --format json
dws doc +update --node <DOC_ID> --command overwrite --doc-format jsonml --content @full.json --expected-revision <REVISION> --format json
dws doc +update --node <DOC_ID> --command block_insert_before --before-block-id <BLOCK_ID> --content "发布说明" --heading-level 1 --format json
dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容" --format json
```
@@ -28,7 +29,8 @@ dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @full.md -
|---|---|---|
| `append` | 末尾追加 | `--content` |
| `overwrite` | 整篇覆盖 | `--content`;JSONML 可加 `--expected-revision` 做服务端原子条件写 |
| `block_insert_after` | 在指定 block 后插入 | `--after-block-id --content` |
| `block_insert_before` | 在指定 block 前插入段落或标题 | `--before-block-id --content`;标题加 `--heading-level 1..6` |
| `block_insert_after` | 在指定 block 后插入段落或标题 | `--after-block-id --content`;标题加 `--heading-level 1..6` |
| `block_replace` | 替换指定 block | `--block-id --content` |
| `block_delete` | 删除指定 block | `--block-id` |
| `str_replace` | 唯一普通文本替换 | `--old --new` |
@@ -47,7 +49,7 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止
| 用户明确要求在末尾追加 | 直接 `append` | 不为找末尾先拉全文;需要语气衔接时只读末节 |
| 已知唯一旧文本与新文本 | 直接 `str_replace --old --new` | 省掉 block 解析;旧文本不唯一时 Runtime 必须失败,不放宽匹配 |
| 指定章节但没有 block ID | `+fetch outline` → `+fetch section --detail with-ids` → block 动作 | 两个小读取换取稳定锚点,避免全文 token 和误改相邻章节 |
| 已知真实 block ID | 直接 `block_replace/delete/insert_after` | 最小副作用;不改无关 block |
| 已知真实 block ID | 直接 `block_replace/delete/insert_before/insert_after` | 最小副作用;不改无关 block |
| 多处富结构保真修改 | `+fetch --detail full` 后定点 JSONML 更新 | 保留图片、附件、引用、表格和样式;不要从 Markdown 有损重建 |
| 整篇重要覆盖 | `+checkpoint-update --mode overwrite` | 自动保存恢复点、执行并回读;普通 overwrite 只用于明确不需恢复点的场景 |
@@ -56,7 +58,7 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止
## Block ID 生命周期与保真
- `block_replace` 成功后 Runtime 使用同一 `blockId` 回读验证,该 ID 可继续作为锚点;验证失败时先局部 `+fetch` 核对现状。`block_delete` 成功后旧 ID 失效,不得继续复用。
- `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。
- `block_insert_before` / `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。
- `str_replace` 的简单行内替换通常不要求重新取 ID;若后续依赖块结构,仍以局部回读为准。
- 从 Markdown 读取后覆盖整篇可能丢失图片、附件、@人/@文档、评论锚点、表格样式和嵌套块。只改局部时使用 block 手术;确需整篇保真改写时使用 `full` JSONML,并以 `--expected-revision` 防止覆盖并发修改。