Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
a37e6e6847 | ||
|
|
0ceb96c745 | ||
|
|
114503d52f | ||
|
|
9de1c9c304 | ||
|
|
840e1d665f | ||
|
|
f362c8c2a4 | ||
|
|
e73a1556ce | ||
|
|
186f2fa474 | ||
|
|
d91a93c43b | ||
|
|
08254e2a36 | ||
|
|
fdd9e189d6 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
eebdf52da9 | ||
|
|
a0ca1fdb28 | ||
|
|
9eaee76a51 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
58dfbc5b6e | ||
|
|
d7c28bcfef | ||
|
|
287b079c18 | ||
|
|
50f8ade1d7 | ||
|
|
b87cad1eb5 | ||
|
|
0f2eec145e | ||
|
|
64c2e8544c | ||
|
|
fc31fddd73 | ||
|
|
4298d0833b | ||
|
|
7e0957d9e8 |
@@ -6,6 +6,57 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.57-beta.1] - 2026-08-05
|
||||
|
||||
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||
command-contract and runtime Schema architecture, complete Multi IM Chat
|
||||
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
|
||||
and compatibility and CI reliability fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
|
||||
(alias `set-ownness`) for updating a user's personal status text. The write
|
||||
operation maps reviewed `userId` and `ownnessText` parameters to the service
|
||||
contract and requires confirmation unless `--yes` is explicitly supplied.
|
||||
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
|
||||
`whiteboard query/update`, and `doc media upload`. These commands support
|
||||
confirmed document-embedded whiteboard creation and updates, structured
|
||||
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
|
||||
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
|
||||
stable-ID resolution, sending, querying, downloading, pagination, and JSON
|
||||
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
|
||||
destructive delete and clear operations aligned to confirmation gates.
|
||||
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
|
||||
process forecast, and confirmed approval-instance creation, supporting both
|
||||
simple flags and complete `--request` payloads.
|
||||
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
|
||||
workspace document activity, with cursor paging and optional file exclusion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified command and Schema contract framework** (#830) — Leaf commands and
|
||||
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
|
||||
confirmation, Help, and runtime Schema projection. Schema delivery assembles
|
||||
from leaf Contract declarations at runtime; the retired hint overlays,
|
||||
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
|
||||
authorities.
|
||||
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
|
||||
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
|
||||
reachability contract that prevents native-only tests from being silently
|
||||
excluded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat media-download JSON compatibility** (#854) — restores parseable
|
||||
`success`, `downloadUrl`, and `output` fields for
|
||||
`chat message download-media --format json` after a successful download,
|
||||
without progress output corrupting JSON stdout.
|
||||
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
|
||||
@@ -30,38 +30,29 @@ import (
|
||||
// remains a precise reviewed exception for such a capability whose runtime
|
||||
// preconditions cannot be exercised safely and deterministically in the
|
||||
// isolated test process.
|
||||
type AgentExampleMode string
|
||||
type AgentExampleMode = contract.ExampleDispositionMode
|
||||
|
||||
const (
|
||||
AgentExampleModeContract AgentExampleMode = "contract"
|
||||
AgentExampleModeDryRun AgentExampleMode = "dry_run"
|
||||
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
|
||||
AgentExampleModeContract = contract.ExampleDispositionModeContract
|
||||
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
|
||||
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
|
||||
)
|
||||
|
||||
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
|
||||
// exceptions to an explicit dry-run capability.
|
||||
type AgentExampleReasonCode string
|
||||
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
|
||||
|
||||
const (
|
||||
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
|
||||
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
|
||||
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
|
||||
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
|
||||
)
|
||||
|
||||
// AgentExampleDisposition narrows one exact example with an explicit
|
||||
// typed dry-run capability to contract-only. Index is a pointer so a missing
|
||||
// field cannot silently select example zero.
|
||||
//
|
||||
// Dispositions are authored as an in-test / future ContractFinal extension
|
||||
// surface; production ContractFinal Selection currently does not declare them,
|
||||
// so the delivery plan treats every example as default-typed (contract or
|
||||
// dry_run from ToolSpec.DryRun).
|
||||
type AgentExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode AgentExampleMode `json:"mode"`
|
||||
ReasonCode AgentExampleReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
// Dispositions are authored on the owning ContractFinal Selection.
|
||||
type AgentExampleDisposition = contract.ExampleDisposition
|
||||
|
||||
// AgentExampleExecution is one resolved example and its effective test mode.
|
||||
type AgentExampleExecution struct {
|
||||
|
||||
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
|
||||
out.UseWhen = selection.UseWhen
|
||||
out.AvoidWhen = selection.AvoidWhen
|
||||
out.Examples = selection.Examples
|
||||
out.ExampleDispositions = selection.ExampleDispositions
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
|
||||
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
|
||||
})
|
||||
t.Cleanup(restoreSelection)
|
||||
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
|
||||
selection := contractFinalToolSelection(cmd)
|
||||
selection.ExampleDispositions = []AgentExampleDisposition{{
|
||||
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("plan error = %v", err)
|
||||
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, nil)
|
||||
t.Cleanup(restoreSelection)
|
||||
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
|
||||
selection := contractFinalToolSelection(cmd)
|
||||
selection.ExampleDispositions = []AgentExampleDisposition{{
|
||||
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
|
||||
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
_, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
|
||||
t.Fatalf("error = %v", err)
|
||||
|
||||
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
|
||||
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
|
||||
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
|
||||
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
|
||||
@@ -332,6 +332,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
reviewed := true
|
||||
selection.Reviewed = &reviewed
|
||||
}
|
||||
// Example dispositions control only the policy gate's execution eligibility.
|
||||
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
|
||||
// part of the public ToolSpec / Schema wire contract.
|
||||
selection.ExampleDispositions = nil
|
||||
|
||||
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
|
||||
|
||||
|
||||
@@ -200,6 +200,10 @@ type SelectionSpec struct {
|
||||
Tips []string
|
||||
WorkflowRefs []string
|
||||
Examples []string
|
||||
// ExampleDispositions narrows an exact example with a reviewed local or
|
||||
// stateful precondition from dry-run execution to contract validation.
|
||||
// It does not change the command's declared DryRun capability.
|
||||
ExampleDispositions []ExampleDisposition
|
||||
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
|
||||
// declaration path must not set it: declared selection is final by
|
||||
// construction, and assembly rejects a declared payload carrying it.
|
||||
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
|
||||
out.Tips = stableUniqueStrings(s.Tips)
|
||||
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
|
||||
out.Examples = stableUniqueStrings(s.Examples)
|
||||
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
|
||||
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
|
||||
return out
|
||||
}
|
||||
|
||||
// ExampleDispositionMode controls how an already contract-validated example
|
||||
// is exercised by the Agent example gate.
|
||||
type ExampleDispositionMode string
|
||||
|
||||
const (
|
||||
ExampleDispositionModeContract ExampleDispositionMode = "contract"
|
||||
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
|
||||
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
|
||||
)
|
||||
|
||||
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
|
||||
// contract-only exceptions to an explicit dry-run capability.
|
||||
type ExampleDispositionReasonCode string
|
||||
|
||||
const (
|
||||
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
|
||||
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
|
||||
)
|
||||
|
||||
// ExampleDisposition narrows one exact example to contract-only validation.
|
||||
// Index is a pointer so a missing index cannot silently select example zero.
|
||||
type ExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode ExampleDispositionMode `json:"mode"`
|
||||
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := append([]ExampleDisposition(nil), in...)
|
||||
for i := range out {
|
||||
if out[i].Index != nil {
|
||||
index := *out[i].Index
|
||||
out[i].Index = &index
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParamDecl is one parameter-level Schema fact declared on a command. It is
|
||||
// stored at DeclareLeafMetadata time and applied as annotations at assembly
|
||||
// time, when all flags are guaranteed to exist on the fully-built command tree.
|
||||
|
||||
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
|
||||
exampleIndex := 0
|
||||
normalized := (SelectionSpec{
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
ExampleDispositions: []ExampleDisposition{{
|
||||
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
|
||||
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
|
||||
}},
|
||||
SourceRefs: []string{"b", "a", "b"},
|
||||
}).Normalized()
|
||||
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
|
||||
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
|
||||
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
|
||||
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
|
||||
}
|
||||
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
|
||||
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
|
||||
}
|
||||
exampleIndex = 1
|
||||
if *normalized.ExampleDispositions[0].Index != 0 {
|
||||
t.Fatal("ExampleDispositions index was not cloned")
|
||||
}
|
||||
if got := cloneExampleDispositions(nil); got != nil {
|
||||
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
|
||||
}
|
||||
if got := stableUniqueStrings(nil); got != nil {
|
||||
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
|
||||
命令结构:
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
|
||||
dws calendar attendee [list|add|delete] 参会人管理
|
||||
dws calendar room [search|add|delete|list-groups] 会议室管理
|
||||
dws calendar busy search 闲忙查询 (可查人、查会议室)
|
||||
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
|
||||
eventSuggestCmd.Flags().String("members", "", "")
|
||||
_ = eventSuggestCmd.Flags().MarkHidden("members")
|
||||
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
|
||||
|
||||
eventInstancesCmd := &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "查询循环日程的实例列表",
|
||||
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
|
||||
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
|
||||
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
|
||||
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
|
||||
Example: ` dws calendar event instances --id EVENT_ID
|
||||
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
|
||||
dws calendar event instances --id EVENT_ID --limit 50
|
||||
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"eventId": eventID}
|
||||
var startTime, endTime int64
|
||||
var now time.Time
|
||||
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
|
||||
startTime, err = parseISOTimeToMillis("start", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = startTime
|
||||
} else {
|
||||
now = time.Now()
|
||||
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
toolArgs["startTime"] = startTime
|
||||
}
|
||||
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
|
||||
endTime, err = parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = endTime
|
||||
} else {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
toolArgs["endTime"] = endTime
|
||||
}
|
||||
if err := validateTimeRange(startTime, endTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
|
||||
toolArgs["calendarId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
}
|
||||
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: "list_event_instances",
|
||||
CanonicalPath: "calendar.list_event_instances",
|
||||
CLIPath: "calendar event instances",
|
||||
PrimaryCLIPath: "calendar event instances",
|
||||
},
|
||||
Description: "查询循环日程的实例列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询循环日程在时间范围内展开的实例",
|
||||
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
|
||||
AvoidWhen: []string{
|
||||
"普通非循环日程请用 dws calendar event get / list",
|
||||
"未知 eventId 时先 dws calendar event list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws calendar event instances --id <EVENT_ID>",
|
||||
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "eventId", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// InstancesEvent flags (aligned with event list aliases)
|
||||
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
|
||||
eventInstancesCmd.Flags().String("event", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event")
|
||||
eventInstancesCmd.Flags().String("event-id", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
|
||||
eventInstancesCmd.Flags().String("eventId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
|
||||
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-min", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
|
||||
eventInstancesCmd.Flags().String("min-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
|
||||
eventInstancesCmd.Flags().String("start-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
|
||||
eventInstancesCmd.Flags().String("startTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
|
||||
eventInstancesCmd.Flags().String("start_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
|
||||
eventInstancesCmd.Flags().String("start-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
|
||||
eventInstancesCmd.Flags().String("startDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
|
||||
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-max", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
|
||||
eventInstancesCmd.Flags().String("max-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
|
||||
eventInstancesCmd.Flags().String("end-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
|
||||
eventInstancesCmd.Flags().String("endTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
|
||||
eventInstancesCmd.Flags().String("end_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
|
||||
eventInstancesCmd.Flags().String("end-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
|
||||
eventInstancesCmd.Flags().String("endDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
|
||||
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
|
||||
eventInstancesCmd.Flags().String("calendarId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
|
||||
eventInstancesCmd.Flags().String("calendar", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
|
||||
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
|
||||
eventInstancesCmd.Flags().String("next-cursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
|
||||
eventInstancesCmd.Flags().String("nextCursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
|
||||
eventInstancesCmd.Flags().String("page-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
|
||||
eventInstancesCmd.Flags().String("pageToken", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
|
||||
eventInstancesCmd.Flags().String("next-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
|
||||
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
|
||||
eventInstancesCmd.Flags().Int("max-results", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
|
||||
eventInstancesCmd.Flags().Int("maxResults", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
|
||||
eventInstancesCmd.Flags().Int("page-size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
|
||||
eventInstancesCmd.Flags().Int("size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("size")
|
||||
eventInstancesCmd.Flags().Int("count", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("count")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
|
||||
|
||||
// participant
|
||||
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
|
||||
|
||||
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactUserUpdateOwnnessCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update-ownness",
|
||||
Aliases: []string{"set-ownness"},
|
||||
Short: "更新用户个人状态",
|
||||
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
|
||||
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
|
||||
return err
|
||||
}
|
||||
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
|
||||
if userID == "" {
|
||||
return fmt.Errorf("--user-id 不能为空")
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
|
||||
return err
|
||||
}
|
||||
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
|
||||
if ownnessText == "" {
|
||||
return fmt.Errorf("--ownness-text 不能为空")
|
||||
}
|
||||
return callMCPTool("user_ownness_update", map[string]any{
|
||||
"userId": userID,
|
||||
"ownnessText": ownnessText,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
|
||||
cmd.Flags().String("id", "", "--user-id 的别名")
|
||||
cmd.Flags().String("userid", "", "--user-id 的别名")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("userid")
|
||||
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactAccountUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
|
||||
|
||||
通讯录功能:
|
||||
- contact user get-self/search/search-mobile/get: 通讯录用户查询
|
||||
- contact user invite/update/update-self: 邀请与更新员工
|
||||
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
|
||||
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
|
||||
- contact relation list-my-followings: 特别关注人查询
|
||||
|
||||
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
|
||||
- 查询用户的部门、主管、管理员权限 → contact user get
|
||||
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
|
||||
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
|
||||
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
|
||||
- 邀请员工加入企业 → contact user invite
|
||||
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
|
||||
- 查询离职员工列表 → contact user dismission search`,
|
||||
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
|
||||
},
|
||||
},
|
||||
})
|
||||
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
|
||||
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "contact",
|
||||
Name: "user_ownness_update",
|
||||
CanonicalPath: "contact.user_ownness_update",
|
||||
CLIPath: "contact user update-ownness",
|
||||
PrimaryCLIPath: "contact user update-ownness",
|
||||
},
|
||||
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
|
||||
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
|
||||
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
|
||||
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "userId", Required: boolPtr(false)},
|
||||
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
|
||||
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
|
||||
{Name: "userid", Property: "userId", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ── flags 注册 ───────────────────────────────────────────────
|
||||
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
|
||||
_ = contactUserGetCmd.Flags().MarkHidden("userid")
|
||||
userCmd.AddCommand(
|
||||
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserUpdateOwnnessCmd, // 更新用户个人状态
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
)
|
||||
|
||||
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
|
||||
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
|
||||
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
|
||||
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
|
||||
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
|
||||
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
|
||||
toolName: "self_user_profile_update",
|
||||
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
|
||||
},
|
||||
{
|
||||
name: "update user ownness",
|
||||
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
|
||||
toolName: "user_ownness_update",
|
||||
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
|
||||
},
|
||||
{
|
||||
name: "update user ownness with aliases",
|
||||
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
|
||||
toolName: "user_ownness_update",
|
||||
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
|
||||
},
|
||||
{
|
||||
name: "update enterprise account",
|
||||
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
|
||||
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
|
||||
{"dept", "update", "--dept", "7", "--name", "研发中心"},
|
||||
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
|
||||
{"user", "update-self", "--nick", "新昵称"},
|
||||
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
|
||||
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
|
||||
}
|
||||
for _, args := range tests {
|
||||
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
|
||||
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
|
||||
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
|
||||
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
|
||||
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
|
||||
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
|
||||
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
|
||||
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
|
||||
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
|
||||
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
|
||||
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file is adapted from Go standard library's internal/diff package.
|
||||
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
|
||||
//
|
||||
// Modifications:
|
||||
// - Package changed from "diff" to "products"
|
||||
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
|
||||
// - const C replaced with the contextLines parameter
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A pair is a pair of values tracked for both the x and y side of a diff.
|
||||
// It is typically a pair of line indexes.
|
||||
type diffPair struct{ x, y int }
|
||||
|
||||
func nonNeg(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UnifiedDiff returns an anchored diff of the two texts old and new
|
||||
// in the "unified diff" format. If old and new are identical,
|
||||
// UnifiedDiff returns a nil slice (no output).
|
||||
//
|
||||
// Unix diff implementations typically look for a diff with
|
||||
// the smallest number of lines inserted and removed,
|
||||
// which can in the worst case take time quadratic in the
|
||||
// number of lines in the texts. As a result, many implementations
|
||||
// either can be made to run for a long time or cut off the search
|
||||
// after a predetermined amount of work.
|
||||
//
|
||||
// In contrast, this implementation looks for a diff with the
|
||||
// smallest number of "unique" lines inserted and removed,
|
||||
// where unique means a line that appears just once in both old and new.
|
||||
// We call this an "anchored diff" because the unique lines anchor
|
||||
// the chosen matching regions. An anchored diff is usually clearer
|
||||
// than a standard diff, because the algorithm does not try to
|
||||
// reuse unrelated blank lines or closing braces.
|
||||
// The algorithm also guarantees to run in O(n log n) time
|
||||
// instead of the standard O(n²) time.
|
||||
//
|
||||
// Some systems call this approach a "patience diff," named for
|
||||
// the "patience sorting" algorithm, itself named for a solitaire card game.
|
||||
// We avoid that name for two reasons. First, the name has been used
|
||||
// for a few different variants of the algorithm, so it is imprecise.
|
||||
// Second, the name is frequently interpreted as meaning that you have
|
||||
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
|
||||
// when in fact the algorithm is faster than the standard one.
|
||||
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
|
||||
if bytes.Equal(old, new) {
|
||||
return nil
|
||||
}
|
||||
x := diffLines(old)
|
||||
y := diffLines(new)
|
||||
|
||||
// Print diff header.
|
||||
var out bytes.Buffer
|
||||
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
|
||||
fmt.Fprintf(&out, "--- %s\n", oldName)
|
||||
fmt.Fprintf(&out, "+++ %s\n", newName)
|
||||
|
||||
// Loop over matches to consider,
|
||||
// expanding each match to include surrounding lines,
|
||||
// and then printing diff chunks.
|
||||
// To avoid setup/teardown cases outside the loop,
|
||||
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
|
||||
// in the sequence of matches.
|
||||
var (
|
||||
done diffPair // printed up to x[:done.x] and y[:done.y]
|
||||
chunk diffPair // start lines of current chunk
|
||||
count diffPair // number of lines from each side in current chunk
|
||||
ctext []string // lines for current chunk
|
||||
)
|
||||
for _, m := range diffTgs(x, y) {
|
||||
if m.x < done.x {
|
||||
// Already handled scanning forward from earlier match.
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand matching lines as far as possible,
|
||||
// establishing that x[start.x:end.x] == y[start.y:end.y].
|
||||
// Note that on the first (or last) iteration we may (or definitely do)
|
||||
// have an empty match: start.x==end.x and start.y==end.y.
|
||||
start := m
|
||||
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
|
||||
start.x--
|
||||
start.y--
|
||||
}
|
||||
end := m
|
||||
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
|
||||
end.x++
|
||||
end.y++
|
||||
}
|
||||
|
||||
// Emit the mismatched lines before start into this chunk.
|
||||
// (No effect on first sentinel iteration, when start = {0,0}.)
|
||||
for _, s := range x[done.x:start.x] {
|
||||
ctext = append(ctext, "-"+s)
|
||||
count.x++
|
||||
}
|
||||
for _, s := range y[done.y:start.y] {
|
||||
ctext = append(ctext, "+"+s)
|
||||
count.y++
|
||||
}
|
||||
|
||||
// If we're not at EOF and have too few common lines,
|
||||
// the chunk includes all the common lines and continues.
|
||||
C := contextLines
|
||||
if C < 0 {
|
||||
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
|
||||
C = 0
|
||||
}
|
||||
if (end.x < len(x) || end.y < len(y)) &&
|
||||
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
|
||||
for _, s := range x[start.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
continue
|
||||
}
|
||||
|
||||
// End chunk with common lines for context.
|
||||
if len(ctext) > 0 {
|
||||
n := end.x - start.x
|
||||
if n > C {
|
||||
n = C
|
||||
}
|
||||
for _, s := range x[start.x : start.x+n] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = diffPair{start.x + n, start.y + n}
|
||||
|
||||
// Format and emit chunk.
|
||||
// Convert line numbers to 1-indexed.
|
||||
// Special case: empty file shows up as 0,0 not 1,0.
|
||||
if count.x > 0 {
|
||||
chunk.x++
|
||||
}
|
||||
if count.y > 0 {
|
||||
chunk.y++
|
||||
}
|
||||
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
|
||||
for _, s := range ctext {
|
||||
out.WriteString(s)
|
||||
}
|
||||
count.x = 0
|
||||
count.y = 0
|
||||
ctext = ctext[:0]
|
||||
}
|
||||
|
||||
// If we reached EOF, we're done.
|
||||
if end.x >= len(x) && end.y >= len(y) {
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise start a new chunk.
|
||||
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
|
||||
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
|
||||
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
|
||||
for _, s := range x[chunk.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// diffLines returns the lines in the file x, including newlines.
|
||||
// If the file does not end in a newline, one is supplied
|
||||
// along with a warning about the missing newline.
|
||||
func diffLines(x []byte) []string {
|
||||
l := strings.SplitAfter(string(x), "\n")
|
||||
if l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
} else {
|
||||
// Treat last line as having a message about the missing newline attached,
|
||||
// using the same text as BSD/GNU diff (including the leading backslash).
|
||||
l[len(l)-1] += "\n\\ No newline at end of file\n"
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// diffTgs returns the pairs of indexes of the longest common subsequence
|
||||
// of unique lines in x and y, where a unique line is one that appears
|
||||
// once in x and once in y.
|
||||
//
|
||||
// The longest common subsequence algorithm is as described in
|
||||
// Thomas G. Szymanski, "A Special Case of the Maximal Common
|
||||
// Subsequence Problem," Princeton TR #170 (January 1975),
|
||||
// available at https://research.swtch.com/tgs170.pdf.
|
||||
func diffTgs(x, y []string) []diffPair {
|
||||
// Count the number of times each string appears in a and b.
|
||||
// We only care about 0, 1, many, counted as 0, -1, -2
|
||||
// for the x side and 0, -4, -8 for the y side.
|
||||
// Using negative numbers now lets us distinguish positive line numbers later.
|
||||
m := make(map[string]int)
|
||||
for _, s := range x {
|
||||
if c := m[s]; c > -2 {
|
||||
m[s] = c - 1
|
||||
}
|
||||
}
|
||||
for _, s := range y {
|
||||
if c := m[s]; c > -8 {
|
||||
m[s] = c - 4
|
||||
}
|
||||
}
|
||||
|
||||
// Now unique strings can be identified by m[s] = -1+-4.
|
||||
//
|
||||
// Gather the indexes of those strings in x and y, building:
|
||||
// xi[i] = increasing indexes of unique strings in x.
|
||||
// yi[i] = increasing indexes of unique strings in y.
|
||||
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
|
||||
var xi, yi, inv []int
|
||||
for i, s := range y {
|
||||
if m[s] == -1+-4 {
|
||||
m[s] = len(yi)
|
||||
yi = append(yi, i)
|
||||
}
|
||||
}
|
||||
for i, s := range x {
|
||||
if j, ok := m[s]; ok && j >= 0 {
|
||||
xi = append(xi, i)
|
||||
inv = append(inv, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Algorithm A from Szymanski's paper.
|
||||
// In those terms, A = J = inv and B = [0, n).
|
||||
// We add sentinel pairs {0,0}, and {len(x),len(y)}
|
||||
// to the returned sequence, to help the processing loop.
|
||||
J := inv
|
||||
n := len(xi)
|
||||
T := make([]int, n)
|
||||
L := make([]int, n)
|
||||
for i := range T {
|
||||
T[i] = n + 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
k := sort.Search(n, func(k int) bool {
|
||||
return T[k] >= J[i]
|
||||
})
|
||||
T[k] = J[i]
|
||||
L[i] = k + 1
|
||||
}
|
||||
k := 0
|
||||
for _, v := range L {
|
||||
if k < v {
|
||||
k = v
|
||||
}
|
||||
}
|
||||
seq := make([]diffPair, 2+k)
|
||||
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
|
||||
lastj := n
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if L[i] == k && J[i] < lastj {
|
||||
seq[k] = diffPair{xi[i], yi[J[i]]}
|
||||
k--
|
||||
}
|
||||
}
|
||||
seq[0] = diffPair{0, 0} // sentinel at start
|
||||
return seq
|
||||
}
|
||||
+64
-7
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc create 创建文档
|
||||
dws doc update 更新文档内容
|
||||
dws doc block [list|insert|update|delete] 块级编辑
|
||||
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
|
||||
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
|
||||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
|
||||
|
||||
mediaUploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传可复用的文档媒体资源",
|
||||
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
|
||||
|
||||
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
|
||||
等后续写入使用;临时 uploadUrl 不会输出。`,
|
||||
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
|
||||
RunE: runDocMediaUpload,
|
||||
}
|
||||
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "media_upload",
|
||||
CanonicalPath: "doc.media_upload",
|
||||
CLIPath: "doc media upload",
|
||||
PrimaryCLIPath: "doc media upload",
|
||||
},
|
||||
Description: "上传可复用的文档媒体资源",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
|
||||
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
|
||||
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
|
||||
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "file", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
|
||||
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
|
||||
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||||
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
|
||||
|
||||
mediaInsertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
Short: "上传附件并插入文档",
|
||||
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
|
||||
|
||||
// media 子命令的 --node 隐藏别名
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
|
||||
for _, c := range mediaNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
|
||||
|
||||
// ── comment (文档评论) ──────────────────────────────────
|
||||
commentCmd := &cobra.Command{
|
||||
@@ -3607,11 +3657,13 @@ CLI 内部自动完成全部流程:
|
||||
PROCESSING 处理中
|
||||
SUCCESS 导出成功,返回 downloadUrl
|
||||
FAILED 导出失败`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>
|
||||
dws doc export get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jobID := mustGetFlag(cmd, "job-id")
|
||||
if jobID == "" {
|
||||
return fmt.Errorf("flag --job-id is required")
|
||||
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
|
||||
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
@@ -3678,9 +3730,14 @@ CLI 内部自动完成全部流程:
|
||||
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
|
||||
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "job-id", Property: "jobId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
|
||||
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
|
||||
_ = exportGetCmd.Flags().MarkHidden("task-id")
|
||||
|
||||
// --node 的隐藏别名(与 doc 下其他命令保持一致)
|
||||
exportCmd.Flags().String("url", "", "--node 的别名")
|
||||
@@ -4227,7 +4284,7 @@ CLI 内部自动完成全部流程:
|
||||
folderCmd.Hidden = true
|
||||
permissionCmd.Hidden = true
|
||||
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
|
||||
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
|
||||
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := mustGetFlag(cmd, "file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("flag --file is required")
|
||||
}
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
|
||||
fileName, _ := cmd.Flags().GetString("name")
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
} else if filepath.Ext(fileName) == "" {
|
||||
fileName += filepath.Ext(filePath)
|
||||
}
|
||||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||||
if mimeType == "" {
|
||||
mimeType = inferMimeType(fileName)
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileInfo.Size()),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||||
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
|
||||
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileInfo.Size()),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resourceURL == "" {
|
||||
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
|
||||
}
|
||||
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
|
||||
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
|
||||
return fmt.Errorf("document media upload failed: %s", message)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"resourceId": resourceID,
|
||||
"resourceUrl": resourceURL,
|
||||
"fileName": fileName,
|
||||
"mimeType": mimeType,
|
||||
"size": fileInfo.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
|
||||
whiteboardDefaultHeight = 600
|
||||
)
|
||||
|
||||
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
|
||||
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
|
||||
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
|
||||
// 继续调用 whiteboard query/update。
|
||||
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
|
||||
|
||||
var (
|
||||
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
|
||||
whiteboardSleep = time.Sleep
|
||||
whiteboardJSONMarshal = json.Marshal
|
||||
prepareWhiteboardCard = prepareJsonMLNode
|
||||
)
|
||||
|
||||
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
|
||||
node := []any{
|
||||
"card",
|
||||
map[string]any{
|
||||
"uuid": blockUUID,
|
||||
"cardType": "hetu",
|
||||
"height": whiteboardDefaultHeight,
|
||||
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
|
||||
},
|
||||
[]any{"span", map[string]any{"data-type": "text"},
|
||||
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
|
||||
}
|
||||
out, err := whiteboardJSONMarshal(node)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func extractWhiteboardID(attrs map[string]any) string {
|
||||
meta, _ := attrs["metadata"].(map[string]any)
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
id, _ := meta["id"].(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"blockId": blockID,
|
||||
"format": "jsonml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
blocksField, ok := data["blocks"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
|
||||
}
|
||||
blocks, ok := blocksField.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
|
||||
}
|
||||
var raw string
|
||||
for _, block := range blocks {
|
||||
entry, _ := block.(map[string]any)
|
||||
if entry == nil || entry["blockId"] != blockID {
|
||||
continue
|
||||
}
|
||||
raw, _ = entry["jsonml"].(string)
|
||||
break
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
|
||||
}
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(raw), &node); err != nil {
|
||||
return nil, fmt.Errorf("parse block jsonml: %w", err)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
|
||||
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(node) < 2 {
|
||||
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
|
||||
}
|
||||
attrs, _ := node[1].(map[string]any)
|
||||
if attrs == nil {
|
||||
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockUUID := uuid.New().String()
|
||||
whiteboardID := uuid.New().String()
|
||||
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
|
||||
normalized, err := prepareWhiteboardCard(cmd, element)
|
||||
if err != nil {
|
||||
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"jsonml": normalized,
|
||||
"format": "jsonml",
|
||||
}
|
||||
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
|
||||
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
|
||||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||||
toolArgs["referenceBlockId"] = v
|
||||
where, _ := cmd.Flags().GetString("where")
|
||||
if where == "" {
|
||||
where = "after"
|
||||
}
|
||||
toolArgs["where"] = where
|
||||
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
|
||||
toolArgs["referenceBlockId"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("index") {
|
||||
index, _ := cmd.Flags().GetInt("index")
|
||||
toolArgs["index"] = index
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
|
||||
}
|
||||
|
||||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||||
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
|
||||
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
|
||||
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
|
||||
persistedID := ""
|
||||
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
|
||||
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
|
||||
switch {
|
||||
case queryErr == nil:
|
||||
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
|
||||
persistedID = extractWhiteboardID(attrs)
|
||||
case errors.Is(queryErr, errWhiteboardBlockPending):
|
||||
// 块暂不可见,属于最终一致性,继续重试。
|
||||
default:
|
||||
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
|
||||
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
|
||||
return fmt.Errorf(
|
||||
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
|
||||
blockUUID, queryErr)
|
||||
}
|
||||
if persistedID != "" {
|
||||
break
|
||||
}
|
||||
if attempt < len(whiteboardRetryDelays) {
|
||||
whiteboardSleep(whiteboardRetryDelays[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{"blockId": blockUUID}
|
||||
if persistedID == "" {
|
||||
result["whiteboardId"] = nil
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
|
||||
nodeID, blockUUID))
|
||||
} else {
|
||||
result["whiteboardId"] = persistedID
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
|
||||
}
|
||||
|
||||
func newDocWhiteboardCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "whiteboard",
|
||||
Short: "白板卡片管理",
|
||||
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
insertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
Short: "插入白板卡片",
|
||||
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
|
||||
|
||||
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
|
||||
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
|
||||
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
|
||||
|
||||
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
|
||||
不能同时使用。`,
|
||||
Example: ` dws doc whiteboard insert --node DOC_ID
|
||||
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
|
||||
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
|
||||
RunE: runWhiteboardInsert,
|
||||
}
|
||||
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
|
||||
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
|
||||
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
|
||||
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
|
||||
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
|
||||
|
||||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
|
||||
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
|
||||
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
|
||||
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
|
||||
|
||||
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
insertCmd.Flags().String(name, "", "--node 的兼容别名")
|
||||
_ = insertCmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
DeclareLeafMetadata(insertCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "whiteboard_insert",
|
||||
CanonicalPath: "doc.whiteboard_insert",
|
||||
CLIPath: "doc whiteboard insert",
|
||||
PrimaryCLIPath: "doc whiteboard insert",
|
||||
},
|
||||
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||||
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
|
||||
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
|
||||
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(insertCmd)
|
||||
return root
|
||||
}
|
||||
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
|
||||
Example: ` dws drive list --limit 20
|
||||
dws drive list --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --workspace <workspaceId>
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>`,
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>
|
||||
dws drive list --latest 5
|
||||
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern, _ := cmd.Flags().GetString("pattern")
|
||||
|
||||
depth, _ := cmd.Flags().GetInt("depth")
|
||||
|
||||
latest, _ := cmd.Flags().GetInt("latest")
|
||||
if cmd.Flags().Changed("latest") {
|
||||
if err := validateDriveListLatest(cmd, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
|
||||
}
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 {
|
||||
if depth > 1 || latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
if latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
baseArgs["spaceId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
baseArgs["withThumbnail"] = true
|
||||
}
|
||||
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
|
||||
if rootFolder != "" {
|
||||
if err := validateDriveParentID(rootFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
|
||||
}
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
|
||||
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
"dws drive list --folder <dentryUuid> --limit 20 --format json",
|
||||
"dws drive list --latest 5 --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
|
||||
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
|
||||
{Name: "parallel", Description: "分片下载并发数(1-8)"},
|
||||
{Name: "no-resume", Description: "关闭断点续传"},
|
||||
// Wukong compat alias: routes to download-version; not a download_file property.
|
||||
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
// Wukong compat: `drive download --version N` routes to download-version.
|
||||
origDriveDownloadRunE := driveDownloadCmd.RunE
|
||||
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("version") {
|
||||
return driveDownloadVersionCmd.RunE(cmd, args)
|
||||
}
|
||||
return origDriveDownloadRunE(cmd, args)
|
||||
}
|
||||
|
||||
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
|
||||
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
|
||||
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// ── cross-product hidden aliases ──
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveCoverCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveCoverCmd)
|
||||
|
||||
// ── drive revert (回滚文件到指定历史版本) ──
|
||||
driveRevertCmd := &cobra.Command{
|
||||
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveRevertCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveRevertCmd)
|
||||
|
||||
for _, child := range driveStarCmd.Commands() {
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
if child.Flags().Lookup(alias) == nil {
|
||||
child.Flags().String(alias, "", "--node 的别名")
|
||||
_ = child.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
|
||||
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +220,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +233,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -249,6 +249,12 @@ bfs:
|
||||
item["depth"] = folder.depth + 1
|
||||
item["parentId"] = folder.id // 根级为空串
|
||||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||||
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
|
||||
if ms, ok := driveItemModifiedMillis(item); ok {
|
||||
item["sortTime"] = ms
|
||||
} else {
|
||||
item["sortTime"] = int64(0)
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= driveDepthMaxItems {
|
||||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||||
@@ -291,7 +297,7 @@ bfs:
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -324,18 +330,26 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern)
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
}
|
||||
maxDepth := 0
|
||||
for _, item := range items {
|
||||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
|
||||
driveLatestMax = 50
|
||||
// 钉盘单层 latest 扫描上限(50×20 页)。
|
||||
driveLatestScanMax = 1000
|
||||
)
|
||||
|
||||
// validateDriveListLatest --latest 边界与互斥校验。
|
||||
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
|
||||
if latest < 1 || latest > driveLatestMax {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return driveLatestExclusiveError(f, latest)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return driveLatestExclusiveError("limit", latest)
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||||
return driveLatestExclusiveError("cursor", latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveLatestExclusiveError(flag string, latest int) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
}
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
ti, _ := files[i]["sortTime"].(int64)
|
||||
tj, _ := files[j]["sortTime"].(int64)
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
ri, _ := files[i]["rel_path"].(string)
|
||||
rj, _ := files[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
|
||||
})
|
||||
if len(files) > latest {
|
||||
files = files[:latest]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func stripDriveDepthDecorations(items []map[string]any) {
|
||||
for _, item := range items {
|
||||
delete(item, "depth")
|
||||
delete(item, "parentId")
|
||||
delete(item, "rel_path")
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
}
|
||||
|
||||
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
|
||||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||||
if v, ok := item[k]; ok {
|
||||
if ms, ok := toMillis(v); ok {
|
||||
return ms, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func toMillis(v any) (int64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(t), true
|
||||
case json.Number:
|
||||
if n, err := t.Int64(); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
if tm, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return tm.UnixMilli(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// runDriveListLatest 钉盘单层 --latest 扫描。
|
||||
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
|
||||
buildArgs := func(pageToken string) map[string]any {
|
||||
args := map[string]any{
|
||||
"maxResults": float64(driveDepthPageSize),
|
||||
"orderBy": "modifyTime",
|
||||
"order": "desc",
|
||||
}
|
||||
for k, v := range baseArgs {
|
||||
args[k] = v
|
||||
}
|
||||
if rootFolder != "" {
|
||||
args["parentId"] = rootFolder
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"tool": "list_files",
|
||||
"args": buildArgs(""),
|
||||
"latest": latest,
|
||||
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
|
||||
})
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
collected := make([]map[string]any, 0, latest)
|
||||
scanned := 0
|
||||
pageToken := ""
|
||||
maxPages := driveLatestScanMax/driveDepthPageSize + 1
|
||||
for pages := 0; pages < maxPages; pages++ {
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
|
||||
}
|
||||
items, next, _ := parseDriveDepthPage(text)
|
||||
for _, item := range items {
|
||||
scanned++
|
||||
if isDriveDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if pattern != "" && !matchDriveNamePattern(name, pattern) {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= latest {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
|
||||
break
|
||||
}
|
||||
pageToken = next
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
|
||||
}
|
||||
}
|
||||
if len(collected) < latest {
|
||||
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
|
||||
if pattern != "" {
|
||||
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"items": collected})
|
||||
}
|
||||
+489
-3
@@ -195,7 +195,72 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
|
||||
mailboxSharedWithMeCmd := &cobra.Command{
|
||||
Use: "shared-with-me",
|
||||
Short: "查询共享给我的邮箱",
|
||||
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
|
||||
|
||||
共享关系(relationships)取值:
|
||||
LOGIN 登录(可登录该共享邮箱)
|
||||
SEND_AS 代发(以该邮箱身份发送邮件)
|
||||
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
|
||||
|
||||
返回字段:
|
||||
total 可访问的共享账号总数
|
||||
targets 可访问的共享账号列表`,
|
||||
Example: ` dws mail mailbox shared-with-me
|
||||
dws mail mailbox shared-with-me --limit 20 --offset 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
toolArgs["limit"] = limit
|
||||
}
|
||||
if cmd.Flags().Changed("offset") {
|
||||
offset, _ := cmd.Flags().GetInt("offset")
|
||||
toolArgs["offset"] = offset
|
||||
}
|
||||
if len(toolArgs) == 0 {
|
||||
return callMCPTool("list_shared_with_me", nil)
|
||||
}
|
||||
return callMCPTool("list_shared_with_me", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_shared_with_me",
|
||||
CanonicalPath: "mail.list_shared_with_me",
|
||||
CLIPath: "mail mailbox shared-with-me",
|
||||
PrimaryCLIPath: "mail mailbox shared-with-me",
|
||||
},
|
||||
Description: "查询共享给我的邮箱",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询他人共享给当前用户的邮箱",
|
||||
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
|
||||
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
|
||||
Examples: []string{"dws mail mailbox shared-with-me"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
{Name: "offset", Property: "offset", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
|
||||
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -2126,9 +2191,251 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
|
||||
|
||||
messageExportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出/备份邮件(EML格式)",
|
||||
Long: `导出指定邮件为 EML 格式文件并保存到本地。
|
||||
|
||||
不指定 --filename 时,默认以邮件主题作为文件名。
|
||||
文件保存在当前工作目录下,扩展名为 .eml。
|
||||
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
|
||||
|
||||
注意:目前仅支持 100KB 以内的邮件导出。
|
||||
|
||||
编排流程:
|
||||
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
|
||||
2. 调用 export_message_mime 获取 EML 内容
|
||||
3. 将 EML 内容原子写入本地文件`,
|
||||
Example: ` dws mail message export --email user@company.com --id <messageId>
|
||||
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
email := mustGetFlag(cmd, "email")
|
||||
messageID := mustGetFlag(cmd, "id")
|
||||
filename := mustGetFlag(cmd, "filename")
|
||||
overwrite, _ := cmd.Flags().GetBool("overwrite")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
|
||||
deps.Out.PrintKeyValue("email", email)
|
||||
deps.Out.PrintKeyValue("messageId", messageID)
|
||||
if filename != "" {
|
||||
deps.Out.PrintKeyValue("filename", filename)
|
||||
}
|
||||
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
|
||||
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if filename == "" {
|
||||
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
|
||||
"email": email,
|
||||
"messageId": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件信息失败: %w", err)
|
||||
}
|
||||
var msgData map[string]any
|
||||
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
|
||||
data := msgData
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if msg, ok := data["message"].(map[string]any); ok {
|
||||
data = msg
|
||||
}
|
||||
if subj, ok := data["subject"].(string); ok && subj != "" {
|
||||
filename = subj
|
||||
}
|
||||
}
|
||||
if filename == "" {
|
||||
filename = messageID
|
||||
}
|
||||
}
|
||||
filename = sanitizeMailFilename(filename)
|
||||
|
||||
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
|
||||
"email": email,
|
||||
"id": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出邮件失败: %w", err)
|
||||
}
|
||||
var exportData map[string]any
|
||||
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
|
||||
return fmt.Errorf("解析导出结果失败: %w", err)
|
||||
}
|
||||
if result, ok := exportData["result"].(map[string]any); ok {
|
||||
exportData = result
|
||||
}
|
||||
emlContent, _ := exportData["emlContent"].(string)
|
||||
if emlContent == "" {
|
||||
return fmt.Errorf("导出结果为空: %s", exportText)
|
||||
}
|
||||
destPath := filename + ".eml"
|
||||
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
|
||||
}
|
||||
return fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageExportCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "export_message_mime",
|
||||
CanonicalPath: "mail.export_message_mime",
|
||||
CLIPath: "mail message export",
|
||||
PrimaryCLIPath: "mail message export",
|
||||
},
|
||||
Description: "导出/备份邮件为本地 EML 文件",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "导出邮件为本地 EML 备份",
|
||||
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
|
||||
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
|
||||
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "filename", Property: "filename"},
|
||||
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
|
||||
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
|
||||
|
||||
messageShareToChatCmd := &cobra.Command{
|
||||
Use: "share-to-chat",
|
||||
Short: "[危险] 分享邮件至IM聊天",
|
||||
Long: `将指定邮件分享到钉钉单聊。
|
||||
|
||||
参数说明:
|
||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||
--yes 跳过二次确认,直接执行分享
|
||||
|
||||
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
|
||||
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
|
||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
|
||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
mcpArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
}
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
mcpArgs["uids"] = parseRecipients(users)
|
||||
}
|
||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
|
||||
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
|
||||
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
deps.Out.PrintKeyValue("users", users)
|
||||
}
|
||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
|
||||
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
||||
return nil
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分享邮件失败: %w", err)
|
||||
}
|
||||
var firstResult map[string]any
|
||||
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
|
||||
return fmt.Errorf("解析分享结果失败: %w", err)
|
||||
}
|
||||
if result, ok := firstResult["result"].(map[string]any); ok {
|
||||
firstResult = result
|
||||
}
|
||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||
riskMsg, _ := firstResult["riskMessage"].(string)
|
||||
if !skipConfirm {
|
||||
if riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
|
||||
}
|
||||
mcpArgs["sign"] = sign
|
||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||
}
|
||||
// firstResult is already a parsed object (possibly unwrapped from result).
|
||||
return deps.Out.PrintJSON(firstResult)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "share_message_to_chat",
|
||||
CanonicalPath: "mail.share_message_to_chat",
|
||||
CLIPath: "mail message share-to-chat",
|
||||
PrimaryCLIPath: "mail message share-to-chat",
|
||||
},
|
||||
Description: "分享邮件至 IM 单聊",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把邮件分享到钉钉单聊",
|
||||
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
|
||||
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
|
||||
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "users", Property: "uids"},
|
||||
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -3400,7 +3707,129 @@ object 与 operation 合法组合:
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
|
||||
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
|
||||
calendarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出用户可访问的日历列表",
|
||||
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
|
||||
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
|
||||
Example: ` dws mail calendar list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendars", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendars",
|
||||
CanonicalPath: "mail.list_mailbox_calendars",
|
||||
CLIPath: "mail calendar list",
|
||||
PrimaryCLIPath: "mail calendar list",
|
||||
},
|
||||
Description: "列出邮箱日历文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出邮箱可访问的日历文件夹",
|
||||
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
|
||||
Examples: []string{"dws mail calendar list --email user@company.com"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarCmd.AddCommand(calendarListCmd)
|
||||
|
||||
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
|
||||
calendarEventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询指定日历时间范围内的日程",
|
||||
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
|
||||
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
|
||||
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": flagOrFallback(cmd, "id", "folder-id"),
|
||||
"startTime": flagOrFallback(cmd, "start", "start-time"),
|
||||
"endTime": flagOrFallback(cmd, "end", "end-time"),
|
||||
}
|
||||
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
|
||||
toolArgs["cursor"] = cursor
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendar_events", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendar_events",
|
||||
CanonicalPath: "mail.list_mailbox_calendar_events",
|
||||
CLIPath: "mail calendar-event list",
|
||||
PrimaryCLIPath: "mail calendar-event list",
|
||||
},
|
||||
Description: "查询邮箱日历文件夹时间范围内的日程",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询邮箱日历日程列表",
|
||||
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
|
||||
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime", Required: boolPtr(true)},
|
||||
{Name: "end", Property: "endTime", Required: boolPtr(true)},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
|
||||
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
|
||||
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
|
||||
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
|
||||
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
|
||||
calendarEventCmd.AddCommand(calendarEventListCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -3935,3 +4364,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeMailFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "\x00", "")
|
||||
if name == "" {
|
||||
return "mail"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
|
||||
var mailAtomicLink = os.Link
|
||||
|
||||
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
|
||||
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = tmp.Close()
|
||||
_ = atomicRemove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return fmt.Errorf("设置文件权限失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("写入数据失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("同步磁盘失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
if overwrite {
|
||||
if err := atomicRename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("重命名文件失败: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
if err := mailAtomicLink(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = atomicRemove(tmpName)
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Markdown 文件处理",
|
||||
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
root.AddCommand(
|
||||
newMarkdownFetchCmd(),
|
||||
newMarkdownCreateCmd(),
|
||||
newMarkdownDiffCmd(),
|
||||
newMarkdownOverwriteCmd(),
|
||||
newMarkdownPatchCmd(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws markdown diff
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// diffResult 是 diff 命令的输出结构。
|
||||
type diffResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks int `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
|
||||
var (
|
||||
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
|
||||
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
|
||||
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = computeUnifiedDiff
|
||||
)
|
||||
|
||||
// formatFileSize 返回人类可读的文件大小。
|
||||
func formatFileSize(size int64) string {
|
||||
if size >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
|
||||
}
|
||||
if size >= 1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
|
||||
}
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
|
||||
// checkFileSize 校验文件大小是否超过限制。
|
||||
func checkFileSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxDiffFileSize {
|
||||
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadRemoteContent 下载远端文件内容并返回其文本。
|
||||
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
|
||||
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
|
||||
var text string
|
||||
var err error
|
||||
if versionNum > 0 {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
} else {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": fileID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
|
||||
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
|
||||
// 包级变量以便测试注入。
|
||||
var diffDownloadLimited = defaultDiffDownloadLimited
|
||||
|
||||
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
|
||||
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
|
||||
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
|
||||
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: diffDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Content-Length 预检:可在读取 body 前提前拦截超大文件
|
||||
if resp.ContentLength > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
|
||||
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
|
||||
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
|
||||
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
|
||||
if len(out) == 0 {
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
|
||||
text := string(out)
|
||||
added, deleted, hunks := 0, 0, 0
|
||||
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
|
||||
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
hunks++
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+") {
|
||||
added++
|
||||
} else if strings.HasPrefix(line, "-") {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
changed := added > 0 || deleted > 0
|
||||
return text, added, deleted, hunks, changed
|
||||
}
|
||||
|
||||
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
|
||||
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
|
||||
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
|
||||
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
|
||||
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
|
||||
info := fetchFileInfo(ctx, nodeID)
|
||||
switch info.extension {
|
||||
case "", "md", "markdown":
|
||||
return nil
|
||||
case "adoc":
|
||||
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
|
||||
case "axls":
|
||||
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
|
||||
case "amind", "adraw":
|
||||
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
|
||||
default:
|
||||
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
|
||||
}
|
||||
}
|
||||
|
||||
// newMarkdownDiffCmd 创建 markdown diff 子命令。
|
||||
func newMarkdownDiffCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff",
|
||||
Short: "比较 Markdown 内容差异",
|
||||
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
|
||||
|
||||
模式:
|
||||
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
|
||||
remote_vs_remote: --version V1 (历史版本 vs 最新)
|
||||
remote_vs_local: --file ./local.md (最新 vs 本地)
|
||||
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
|
||||
|
||||
历史版本号通过 dws drive list --versions 获取。
|
||||
--file 与 --version2 不能同时使用。
|
||||
|
||||
限制:
|
||||
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
|
||||
- 单侧文件大小上限: 10 MB
|
||||
- 下载超时: 10 分钟
|
||||
- diff 计算超时: 30 秒`,
|
||||
Example: ` # 比较两个历史版本
|
||||
dws markdown diff --node <dentryUuid> --version 3 --version2 5
|
||||
|
||||
# 历史版本 vs 最新版本
|
||||
dws markdown diff --node <dentryUuid> --version 3
|
||||
|
||||
# 最新版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --file ./draft.md
|
||||
|
||||
# 历史版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version1, _ := cmd.Flags().GetInt("version")
|
||||
version2, _ := cmd.Flags().GetInt("version2")
|
||||
localFile, _ := cmd.Flags().GetString("file")
|
||||
contextLines, _ := cmd.Flags().GetInt("context")
|
||||
|
||||
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
|
||||
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
|
||||
if cmd.Flags().Changed("version") && version1 <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
|
||||
}
|
||||
if cmd.Flags().Changed("version2") && version2 <= 0 {
|
||||
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
|
||||
}
|
||||
if contextLines < 0 {
|
||||
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
|
||||
}
|
||||
|
||||
// 互斥校验:--file 与 --version2 不能同时使用
|
||||
if localFile != "" && version2 > 0 {
|
||||
return fmt.Errorf("--file 与 --version2 不能同时使用")
|
||||
}
|
||||
|
||||
// 模式判定
|
||||
var mode string
|
||||
if localFile != "" {
|
||||
mode = "remote_vs_local"
|
||||
} else {
|
||||
mode = "remote_vs_remote"
|
||||
}
|
||||
|
||||
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
|
||||
if localFile == "" && version1 == 0 && version2 == 0 {
|
||||
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
|
||||
deps.Out.PrintKeyValue("模式", mode)
|
||||
deps.Out.PrintKeyValue("节点ID", nodeID)
|
||||
if version1 > 0 {
|
||||
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("左侧版本", "最新")
|
||||
}
|
||||
if localFile != "" {
|
||||
deps.Out.PrintKeyValue("右侧", localFile)
|
||||
} else if version2 > 0 {
|
||||
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("右侧版本", "最新")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 下载超时 context(与项目其他下载命令一致:10 分钟)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
|
||||
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
|
||||
if localFile != "" {
|
||||
if err := checkFileSize(localFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
|
||||
isJSON := deps.Caller.Format() == "json"
|
||||
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
|
||||
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
|
||||
|
||||
// 下载左侧内容
|
||||
progress("[1/3] 获取左侧内容...")
|
||||
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取右侧内容
|
||||
var rightContent string
|
||||
if localFile != "" {
|
||||
// remote_vs_local: 读取本地文件(大小已校验)
|
||||
progress("[2/3] 读取本地文件...")
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取本地文件失败: %w", err)
|
||||
}
|
||||
rightContent = string(data)
|
||||
} else {
|
||||
// remote_vs_remote: 下载右侧远端内容
|
||||
progress("[2/3] 获取右侧内容...")
|
||||
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 diff(带超时保护)
|
||||
progress("[3/3] 计算差异...")
|
||||
type diffOutput struct {
|
||||
text string
|
||||
added int
|
||||
deleted int
|
||||
hunks int
|
||||
changed bool
|
||||
}
|
||||
resultCh := make(chan diffOutput, 1)
|
||||
// Capture seams before the goroutine so test restorers cannot race
|
||||
// against a still-running compute after the timeout path returns.
|
||||
computeDiff := runMarkdownUnifiedDiff
|
||||
marshalIndent := diffJSONMarshalIndent
|
||||
go func() {
|
||||
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
|
||||
resultCh <- diffOutput{text, added, deleted, hunks, changed}
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
|
||||
result := diffResult{
|
||||
Mode: mode,
|
||||
Changed: changed,
|
||||
AddedLines: added,
|
||||
DeletedLines: deleted,
|
||||
Hunks: hunks,
|
||||
Diff: diffText,
|
||||
}
|
||||
|
||||
// 输出
|
||||
if isJSON {
|
||||
data, err := marshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON 序列化失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintRaw(string(data))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("模式", result.Mode)
|
||||
if result.Changed {
|
||||
deps.Out.PrintKeyValue("是否有变更", "是")
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("是否有变更", "否")
|
||||
}
|
||||
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
|
||||
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
|
||||
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
|
||||
if result.Changed {
|
||||
deps.Out.PrintRaw("")
|
||||
deps.Out.PrintRaw(result.Diff)
|
||||
}
|
||||
}
|
||||
case <-time.After(diffComputeTimeout):
|
||||
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
|
||||
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
|
||||
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
|
||||
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
|
||||
|
||||
// --node 隐藏别名(与 version/fetch 子命令一致)
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
_ = cmd.Flags().MarkHidden("url")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("node-id")
|
||||
_ = cmd.Flags().MarkHidden("doc-id")
|
||||
_ = cmd.Flags().MarkHidden("file-id")
|
||||
|
||||
RegisterCrossProductAliases(cmd)
|
||||
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "markdown",
|
||||
Name: "diff",
|
||||
CanonicalPath: "markdown.diff",
|
||||
CLIPath: "markdown diff",
|
||||
PrimaryCLIPath: "markdown diff",
|
||||
},
|
||||
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
|
||||
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
|
||||
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
|
||||
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "version", Property: "version", InterfaceType: "integer"},
|
||||
{Name: "version2", Property: "version2", InterfaceType: "integer"},
|
||||
{Name: "file", Property: "file"},
|
||||
{Name: "context", Property: "context", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
|
||||
// 探测失败返回零值,由调用方决定是否阻断。
|
||||
type markdownFileInfo struct {
|
||||
name string
|
||||
extension string
|
||||
}
|
||||
|
||||
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
data := resp
|
||||
if result, ok := resp["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if name, ok := data["name"].(string); ok {
|
||||
info.name = name
|
||||
}
|
||||
if ext, ok := data["extension"].(string); ok {
|
||||
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func describeDingTalkDocType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case "adoc":
|
||||
return "文档"
|
||||
case "axls":
|
||||
return "表格"
|
||||
case "amind":
|
||||
return "脑图"
|
||||
case "adraw":
|
||||
return "画图"
|
||||
default:
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
+260
-10
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "minutes",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
|
||||
UseWhen: []string{
|
||||
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
|
||||
},
|
||||
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
|
||||
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
|
||||
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要查看已有热词时改用 dws minutes hot-word list",
|
||||
"要删除热词时改用 dws minutes hot-word delete",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word add --words \"钉钉\"",
|
||||
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
|
||||
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户配置的所有听记热词列表。",
|
||||
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
|
||||
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"要删除热词时改用 hot-word delete",
|
||||
},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
|
||||
hotWordDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("delete_personal_hotword", map[string]any{
|
||||
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "delete_personal_hotword",
|
||||
CanonicalPath: "minutes.delete_personal_hotword",
|
||||
CLIPath: "minutes hot-word delete",
|
||||
PrimaryCLIPath: "minutes hot-word delete",
|
||||
},
|
||||
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
|
||||
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "words", Property: "hotWordList"},
|
||||
},
|
||||
},
|
||||
})
|
||||
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
|
||||
|
||||
// ── replace-text 命令 ───────────────────────────────────────
|
||||
replaceTextCmd := &cobra.Command{
|
||||
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
|
||||
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
|
||||
|
||||
// ── permission 子组 ─────────────────────────────────────────
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission。
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
|
||||
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
|
||||
|
||||
// permission add — 对应 MCP 工具 add_member_permission
|
||||
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
|
||||
AvoidWhen: []string{
|
||||
"要移除成员权限时改用 dws minutes permission remove",
|
||||
"当前用户自己申请访问权限时改用 dws minutes permission apply",
|
||||
"成员、权限策略或听记 id 未确认时不要添加",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加权限时改用 permission add",
|
||||
"当前用户自己申请访问权限时改用 permission apply",
|
||||
"成员或听记 id 未确认时不要移除",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
|
||||
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
|
||||
// permission apply — 对应 MCP 工具 apply_minutes_permission
|
||||
permissionApplyCmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "为当前用户申请听记权限",
|
||||
Long: `为当前登录用户申请指定听记的权限。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
权限类型 (--policy):
|
||||
2 = 可编辑
|
||||
3 = 可查看/下载
|
||||
4 = 仅查看`,
|
||||
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
|
||||
return callMCPTool("apply_minutes_permission", map[string]any{
|
||||
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
|
||||
"policyId": float64(policyID),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "apply_minutes_permission",
|
||||
CanonicalPath: "minutes.apply_minutes_permission",
|
||||
CLIPath: "minutes permission apply",
|
||||
PrimaryCLIPath: "minutes permission apply",
|
||||
},
|
||||
Description: "为当前登录用户申请指定听记的权限。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
|
||||
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
|
||||
AvoidWhen: []string{
|
||||
"所有者批量给他人加权限时改用 permission add",
|
||||
"要移除他人权限时改用 permission remove",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes permission apply --id <taskUuid> --policy 4",
|
||||
"dws minutes permission apply --id <taskUuid> --policy 2",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "taskUuid"},
|
||||
{Name: "policy", Property: "policyId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
|
||||
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("url")
|
||||
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
// ── tag 子组 ────────────────────────────────────────────────
|
||||
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
|
||||
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
|
||||
|
||||
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
|
||||
|
||||
// ── audio-memo 子组 ────────────────────────────
|
||||
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
|
||||
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
|
||||
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
|
||||
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
|
||||
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
|
||||
|
||||
audioMemoListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询语音备忘列表",
|
||||
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
|
||||
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
|
||||
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
|
||||
Example: ` dws minutes audio-memo list
|
||||
dws minutes audio-memo list --max 500
|
||||
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
|
||||
dws minutes audio-memo list --cursor 1740000000000`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
|
||||
max, _ := cmd.Flags().GetFloat64("max")
|
||||
if max <= 0 || max > 1000 {
|
||||
return fmt.Errorf("flag --max must be between 1 and 1000")
|
||||
}
|
||||
toolArgs["pageSize"] = max
|
||||
|
||||
if cmd.Flags().Changed("cursor") {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return fmt.Errorf("flag --cursor must be >= 0")
|
||||
}
|
||||
toolArgs["cursor"] = float64(cursor)
|
||||
}
|
||||
|
||||
startStr, _ := cmd.Flags().GetString("start")
|
||||
endStr, _ := cmd.Flags().GetString("end")
|
||||
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var startMs, endMs int64
|
||||
if startStr != "" {
|
||||
var err error
|
||||
startMs, err = parseISOTimeToMillis("start", startStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if endStr != "" {
|
||||
var err error
|
||||
endMs, err = parseISOTimeToMillis("end", endStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if startStr != "" && endStr != "" {
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return callMCPToolUnescaped("list_audio_memos", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "list_audio_memos",
|
||||
CanonicalPath: "minutes.list_audio_memos",
|
||||
CLIPath: "minutes audio-memo list",
|
||||
PrimaryCLIPath: "minutes audio-memo list",
|
||||
},
|
||||
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
|
||||
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
|
||||
AvoidWhen: []string{
|
||||
"要查听记列表改用 minutes list",
|
||||
"只要某篇听记的音频地址改用 minutes get audio",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes audio-memo list",
|
||||
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "max", Property: "pageSize"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
|
||||
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
|
||||
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
|
||||
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
|
||||
audioMemoCmd.AddCommand(audioMemoListCmd)
|
||||
|
||||
minutesCmd := &cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: "AI 听记 / 会议纪要",
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
return minutesCmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeMarkdownDiff(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "markdown"}, args...)
|
||||
root := newMarkdownCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
|
||||
t.Run("oversized local file", func(t *testing.T) {
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(8))
|
||||
big := filepath.Join(t.TempDir(), "big.md")
|
||||
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(big); err == nil {
|
||||
t.Fatal("expected oversized local file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download helpers and ensure type", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("left\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
|
||||
t.Fatalf("download latest: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
|
||||
t.Fatalf("download version: %v", err)
|
||||
}
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return nil, errors.New("dl boom")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
|
||||
t.Fatal("expected download limited error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
ext string
|
||||
ok bool
|
||||
}{
|
||||
{"md", true}, {"markdown", true}, {"", true},
|
||||
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
|
||||
} {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
|
||||
}})
|
||||
err := ensureMarkdownDiffType(context.Background(), "n1")
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("ext %q: %v", tc.ext, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("ext %q: expected type error", tc.ext)
|
||||
}
|
||||
}
|
||||
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
|
||||
if describeDingTalkDocType(ext) == "" {
|
||||
t.Fatalf("describe %q empty", ext)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
|
||||
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "4")
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
t.Cleanup(okSrv.Close)
|
||||
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
|
||||
if err != nil || string(got) != "data" {
|
||||
t.Fatalf("ok download: %v %q", err, got)
|
||||
}
|
||||
|
||||
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
}))
|
||||
t.Cleanup(badStatus.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
|
||||
t.Fatal("expected non-200")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(3))
|
||||
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = w.Write([]byte("xxxx"))
|
||||
}))
|
||||
t.Cleanup(tooBigHeader.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
|
||||
t.Fatal("expected content-length guard")
|
||||
}
|
||||
|
||||
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// No Content-Length: force LimitReader path to observe oversize body.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "no hijack", 500)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
payload := strings.Repeat("x", 16)
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
|
||||
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
t.Cleanup(chunkedBig.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
|
||||
t.Fatal("expected body size guard")
|
||||
}
|
||||
|
||||
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
|
||||
_ = bufrw.Flush()
|
||||
_ = conn.Close() // truncate body → ReadAll error
|
||||
}))
|
||||
t.Cleanup(readFail.Close)
|
||||
maxDiffFileSize = 1000
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
|
||||
t.Fatal("expected bad url")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
|
||||
t.Fatal("expected canceled ctx")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
|
||||
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
|
||||
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
|
||||
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation and dry-run modes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executeMarkdownDiff(t, "diff"); err == nil {
|
||||
t.Fatal("expected missing node")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
|
||||
t.Fatal("expected version>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
|
||||
t.Fatal("expected version2>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
|
||||
t.Fatal("expected context>=0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected file/version2 mutex")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
|
||||
t.Fatal("expected remote needs version")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
|
||||
t.Fatalf("dry-run remote: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local latest: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local version: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
|
||||
t.Fatalf("dry-run version2-only: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
local := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
|
||||
t.Fatalf("local json: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
|
||||
if strings.Contains(url, "/r") {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
return []byte("gamma\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text changed: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("same\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text unchanged: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"adoc"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
|
||||
t.Fatal("expected type guard")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &maxDiffFileSize)
|
||||
maxDiffFileSize = 2
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
|
||||
t.Fatal("expected local size fail")
|
||||
}
|
||||
maxDiffFileSize = 10 * 1024 * 1024
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
missingLocal := filepath.Join(t.TempDir(), "missing.md")
|
||||
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
|
||||
// Create file for size check, then make ReadFile fail via directory path.
|
||||
dirAsFile := t.TempDir()
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
|
||||
t.Fatal("expected read local dir failure")
|
||||
}
|
||||
_ = missingLocal
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{err: errors.New("right fail")},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
// second download uses version tool — make parse fail on second call via empty resource
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected right download parse fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json marshal and compute timeout", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
testseam.Protect(t, &diffJSONMarshalIndent)
|
||||
testseam.Protect(t, &runMarkdownUnifiedDiff)
|
||||
testseam.Protect(t, &diffComputeTimeout)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("a\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
|
||||
return nil, errors.New("marshal boom")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
|
||||
t.Fatalf("expected marshal err, got %v", err)
|
||||
}
|
||||
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
diffComputeTimeout = time.Millisecond
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("expected timeout, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetchFileInfo extension field", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":".MD","name":"x.md"}`},
|
||||
}})
|
||||
info := fetchFileInfo(context.Background(), "n")
|
||||
if info.extension != "md" || info.name != "x.md" {
|
||||
t.Fatalf("info=%+v", info)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
|
||||
t.Run("message export dry-run and execute", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
|
||||
t.Fatalf("export dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export dry-run default name: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
|
||||
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if _, err := os.Stat("Hello_World.eml"); err != nil {
|
||||
t.Fatalf("missing eml: %v", err)
|
||||
}
|
||||
|
||||
// exist without overwrite
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"x"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
|
||||
t.Fatal("expected exist error")
|
||||
}
|
||||
|
||||
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"y"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller3)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
|
||||
// subject fallback to message id when missing
|
||||
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{}}`},
|
||||
{text: `{"emlContent":"z"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller4)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
|
||||
t.Fatalf("fallback name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("share-to-chat paths", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
|
||||
t.Fatalf("share dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("share dry-run no users: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
|
||||
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
|
||||
// so skipConfirm=false and the server-sign reconfirm branch runs.
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("expected server reconfirm, got %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig"}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig","riskMessage":"careful"}`},
|
||||
{text: `{"ok":true}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("share with yes: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("json success path: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("calendar-event missing folder id", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "u@c.com",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
); err == nil {
|
||||
t.Fatal("expected missing id/folder-id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sanitize and atomic write", func(t *testing.T) {
|
||||
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
}
|
||||
if got := sanitizeMailFilename(" "); got != "mail" {
|
||||
t.Fatalf("empty sanitize=%q", got)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "out.eml")
|
||||
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
|
||||
t.Fatal("expected exist without overwrite")
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
|
||||
t.Fatal("expected create temp fail")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &atomicCreateTemp)
|
||||
testseam.Protect(t, &atomicRemove)
|
||||
testseam.Protect(t, &atomicRename)
|
||||
atomicRemove = func(string) error { return nil }
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{}, nil
|
||||
}
|
||||
atomicRename = func(string, string) error { return errors.New("rename boom") }
|
||||
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export save non-exist failure", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
|
||||
return nil, errors.New("nospc")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"body"}`},
|
||||
}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
|
||||
t.Fatalf("expected save failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetIn(in)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
|
||||
// Expand matches backward + negative context clamp + missing newline marker.
|
||||
old := []byte("a\nb\nc\nd\ne")
|
||||
neu := []byte("a\nb\nC\nd\ne\n")
|
||||
diff := UnifiedDiff("o", old, "n", neu, -3)
|
||||
if len(diff) == 0 {
|
||||
t.Fatal("expected diff with negative context")
|
||||
}
|
||||
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
|
||||
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
|
||||
t.Fatalf("unexpected diff2=%q", diff2)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
|
||||
|
||||
// Non-unique "common" lines before a unique anchor → backward expand (L90).
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
|
||||
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
|
||||
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
|
||||
t.Fatalf("nonNeg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
|
||||
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
|
||||
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
|
||||
{"nodeType": "Folder", "name": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 10)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
|
||||
t.Fatalf("json.Number millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(json.Number("-1")); ok {
|
||||
t.Fatal("negative json.Number")
|
||||
}
|
||||
if _, ok := toMillis(struct{}{}); ok {
|
||||
t.Fatal("unknown type")
|
||||
}
|
||||
|
||||
// pagination + quiet=false progress + shortfall hint with pattern
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
|
||||
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest paginate: %v", err)
|
||||
}
|
||||
|
||||
// nil context uses Background
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
|
||||
t.Fatalf("nil ctx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
|
||||
t.Fatal("expected numeric folder rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
_ = out
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
|
||||
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
|
||||
if buildWhiteboardCardJSONML("b", "w") != "" {
|
||||
t.Fatal("expected empty on marshal fail")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
|
||||
return "", errors.New("bad template")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
|
||||
t.Fatalf("expected prepare fail, got %v", err)
|
||||
}
|
||||
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "doc", "whiteboard"}
|
||||
|
||||
// nil entry in blocks list
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
|
||||
}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
|
||||
t.Fatalf("nil entry skip: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
|
||||
t.Run("hot-word delete dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
|
||||
t.Fatalf("hot-word delete dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete missing words", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
|
||||
t.Fatal("expected missing --words error")
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete executes", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
|
||||
t.Fatalf("hot-word delete: %v", err)
|
||||
}
|
||||
if caller.tool != "delete_personal_hotword" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission apply dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
|
||||
t.Fatalf("permission apply dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply alias uuid", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
|
||||
t.Fatalf("permission apply alias: %v", err)
|
||||
}
|
||||
if caller.tool != "apply_minutes_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
|
||||
t.Fatalf("args=%#v", caller.args)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply invalid policy", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
|
||||
t.Fatal("expected invalid policy")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
|
||||
t.Fatal("expected non-numeric policy")
|
||||
}
|
||||
})
|
||||
t.Run("permission apply missing flags", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
|
||||
t.Fatal("expected missing id")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
|
||||
t.Fatal("expected missing policy")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audio-memo list default", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
|
||||
t.Fatalf("audio-memo list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_audio_memos" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != float64(200) {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newMinutesCommand(),
|
||||
"audio-memo", "list",
|
||||
"--max", "10",
|
||||
"--cursor", "1740000000000",
|
||||
"--start", "2026-01-01T00:00:00+08:00",
|
||||
"--end", "2026-07-21T23:59:59+08:00",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("audio-memo list ranged: %v", err)
|
||||
}
|
||||
if caller.args["cursor"] != float64(1740000000000) {
|
||||
t.Fatalf("cursor=%#v", caller.args["cursor"])
|
||||
}
|
||||
start, _ := caller.args["startTime"].(string)
|
||||
end, _ := caller.args["endTime"].(string)
|
||||
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
|
||||
t.Fatalf("start/end=%q/%q", start, end)
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list validation", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
|
||||
t.Fatal("expected max validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
|
||||
t.Fatal("expected max upper bound")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
|
||||
t.Fatal("expected cursor validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
|
||||
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
|
||||
t.Fatal("expected reversed range error")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
|
||||
t.Fatal("expected bad start")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
|
||||
t.Fatal("expected bad end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
|
||||
// Existing primary --job-id must remain usable.
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
|
||||
t.Fatalf("export get --job-id: %v", err)
|
||||
}
|
||||
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
|
||||
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
|
||||
}
|
||||
|
||||
// Add-only synonym --task-id.
|
||||
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
|
||||
t.Fatalf("export get --task-id: %v", err)
|
||||
}
|
||||
if caller2.args["jobId"] != "job-123" {
|
||||
t.Fatalf("task-id args=%#v", caller2.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
|
||||
t.Run("permission list max-results", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
|
||||
t.Fatalf("cover --file-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
|
||||
t.Fatalf("revert --doc-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("star add url alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
|
||||
t.Fatalf("star add --url: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("download --version routes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
out := filepath.Join(t.TempDir(), "out.pdf")
|
||||
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
|
||||
t.Fatalf("download --version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "a@b.com",
|
||||
"--folder-id", "cal-1",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calendar-event list --folder-id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
|
||||
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
|
||||
t.Fatalf("identical should be nil, got %q", got)
|
||||
}
|
||||
old := []byte("one\ntwo\nthree\n")
|
||||
neu := []byte("one\nTWO\nthree\nfour\n")
|
||||
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
|
||||
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
|
||||
t.Fatalf("expected hunk diff, got %q", diff)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
|
||||
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
|
||||
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().Int("latest", 0, "")
|
||||
cmd.Flags().String("order-by", "", "")
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().Int("limit", 0, "")
|
||||
cmd.Flags().Int("max", 0, "")
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("next-token", "", "")
|
||||
|
||||
if err := validateDriveListLatest(cmd, 0); err == nil {
|
||||
t.Fatal("expected latest lower bound")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd, 51); err == nil {
|
||||
t.Fatal("expected latest upper bound")
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--order-by=name"})
|
||||
if err := validateDriveListLatest(cmd, 3); err == nil {
|
||||
t.Fatal("expected exclusive order-by")
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
cmd2.Flags().Int("latest", 0, "")
|
||||
cmd2.Flags().String("order-by", "", "")
|
||||
cmd2.Flags().String("order", "", "")
|
||||
cmd2.Flags().Int("limit", 0, "")
|
||||
cmd2.Flags().Int("max", 0, "")
|
||||
cmd2.Flags().String("cursor", "", "")
|
||||
cmd2.Flags().String("next-token", "", "")
|
||||
_ = cmd2.ParseFlags([]string{"--limit=10"})
|
||||
if err := validateDriveListLatest(cmd2, 3); err == nil {
|
||||
t.Fatal("expected exclusive limit")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "list"}
|
||||
cmd3.Flags().Int("latest", 0, "")
|
||||
cmd3.Flags().String("order-by", "", "")
|
||||
cmd3.Flags().String("order", "", "")
|
||||
cmd3.Flags().Int("limit", 0, "")
|
||||
cmd3.Flags().Int("max", 0, "")
|
||||
cmd3.Flags().String("cursor", "", "")
|
||||
cmd3.Flags().String("next-token", "", "")
|
||||
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
t.Fatal("expected exclusive cursor")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
// already failed above
|
||||
}
|
||||
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
|
||||
|
||||
items := []map[string]any{
|
||||
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
|
||||
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
|
||||
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("latest len=%d", len(got))
|
||||
}
|
||||
stripDriveDepthDecorations(got)
|
||||
if _, ok := got[0]["sortTime"]; ok {
|
||||
t.Fatal("sortTime should be stripped")
|
||||
}
|
||||
|
||||
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
|
||||
t.Fatalf("float millis=%v %v", ms, ok)
|
||||
}
|
||||
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
|
||||
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(""); ok {
|
||||
t.Fatal("empty string should fail")
|
||||
}
|
||||
if _, ok := toMillis(float64(-1)); ok {
|
||||
t.Fatal("negative float should fail")
|
||||
}
|
||||
if ms, ok := toMillis("42"); !ok || ms != 42 {
|
||||
t.Fatalf("int string=%v %v", ms, ok)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
listCmd := &cobra.Command{Use: "list"}
|
||||
listCmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
|
||||
t.Fatalf("dry-run latest: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
|
||||
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
|
||||
}}}
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
listCmd2 := &cobra.Command{Use: "list"}
|
||||
listCmd2.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest scan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeWhiteboardCommand(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root := newDocWhiteboardCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
|
||||
t.Run("dry-run plan", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("whiteboard insert dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("insert with placement and verify", func(t *testing.T) {
|
||||
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t,
|
||||
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
|
||||
t.Fatalf("whiteboard insert: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("soft success when verify empty", func(t *testing.T) {
|
||||
// Empty blocks are eventual-consistency pending, not hard query failure.
|
||||
caller := &pr868FlexibleCaller{emptyVerify: true}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
|
||||
t.Fatalf("pending-block soft success: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("helpers", func(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
if buildWhiteboardCardJSONML("b", "w") == "" {
|
||||
t.Fatal("empty jsonml")
|
||||
}
|
||||
if extractWhiteboardID(nil) != "" {
|
||||
t.Fatal("nil attrs")
|
||||
}
|
||||
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
|
||||
t.Fatal("extract id")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected query error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing block")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected jsonml parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing attrs")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected nil attrs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type pr868FlexibleCaller struct {
|
||||
whiteboardID string
|
||||
emptyVerify bool
|
||||
format string
|
||||
dry bool
|
||||
}
|
||||
|
||||
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if tool == "insert_document_block" {
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
if tool == "list_document_blocks" {
|
||||
if c.emptyVerify {
|
||||
return textToolResult(`{"blocks":[]}`), nil
|
||||
}
|
||||
blockID, _ := args["blockId"].(string)
|
||||
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
|
||||
return textToolResult(payload), nil
|
||||
}
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
func (c *pr868FlexibleCaller) Format() string { return c.format }
|
||||
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
|
||||
func (*pr868FlexibleCaller) Fields() string { return "" }
|
||||
func (*pr868FlexibleCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
|
||||
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
|
||||
t.Fatal("formatFileSize")
|
||||
}
|
||||
small := filepath.Join(t.TempDir(), "ok.md")
|
||||
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(small); err != nil {
|
||||
t.Fatalf("checkFileSize small: %v", err)
|
||||
}
|
||||
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected missing file")
|
||||
}
|
||||
|
||||
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
|
||||
if !changed || add < 1 || hunks < 1 || diff == "" {
|
||||
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
|
||||
}
|
||||
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
|
||||
t.Fatal("identical should be unchanged")
|
||||
}
|
||||
|
||||
right := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if describeDingTalkDocType("adoc") == "" {
|
||||
t.Fatal("describeDingTalkDocType")
|
||||
}
|
||||
_ = right
|
||||
}
|
||||
@@ -141,18 +141,23 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
|
||||
previousStdin := os.Stdin
|
||||
previousPut := httpPutFile
|
||||
previousGet := httpGetFile
|
||||
previousWhiteboardSleep := whiteboardSleep
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
os.Stdin = previousStdin
|
||||
httpPutFile = previousPut
|
||||
httpGetFile = previousGet
|
||||
whiteboardSleep = previousWhiteboardSleep
|
||||
})
|
||||
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
// Product examples execute real RunE paths; whiteboard insert retries must
|
||||
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
|
||||
whiteboardSleep = func(time.Duration) {}
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
|
||||
if destPath == "" {
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
|
||||
for _, want := range []string{
|
||||
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
|
||||
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
|
||||
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
|
||||
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
|
||||
} {
|
||||
if !seenProducts[want] {
|
||||
t.Errorf("public product %q was not registered", want)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
|
||||
func init() {
|
||||
RegisterPublic(func() Handler {
|
||||
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
whiteboardServerID = "whiteboard"
|
||||
whiteboardQueryTool = "read_whiteboard_content"
|
||||
whiteboardUpdateTool = "update_whiteboard"
|
||||
)
|
||||
|
||||
type whiteboardUpdateFile struct {
|
||||
Overwrite bool `json:"overwrite"`
|
||||
Source *whiteboardOpenSource `json:"source"`
|
||||
}
|
||||
|
||||
type whiteboardOpenSource struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
Nodes json.RawMessage `json:"nodes"`
|
||||
}
|
||||
|
||||
var compactWhiteboardJSON = json.Compact
|
||||
|
||||
func newWhiteboardCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "whiteboard",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
|
||||
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
|
||||
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
Use: "whiteboard",
|
||||
Short: "钉钉文档内嵌白板管理",
|
||||
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
|
||||
|
||||
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
|
||||
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
queryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "读取白板内容",
|
||||
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"partId": mustGetFlag(cmd, "part-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
|
||||
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
|
||||
DeclareLeafMetadata(queryCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "whiteboard",
|
||||
Name: "query",
|
||||
CanonicalPath: "whiteboard.query",
|
||||
CLIPath: "whiteboard query",
|
||||
PrimaryCLIPath: "whiteboard query",
|
||||
},
|
||||
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
|
||||
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
|
||||
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
|
||||
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "追加或整页重建白板内容",
|
||||
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
|
||||
|
||||
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
|
||||
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
|
||||
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
|
||||
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := "append"
|
||||
if input.Overwrite {
|
||||
mode = "overwrite"
|
||||
}
|
||||
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"partId": mustGetFlag(cmd, "part-id"),
|
||||
"mode": mode,
|
||||
"nodes": nodesJSON,
|
||||
})
|
||||
},
|
||||
}
|
||||
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
|
||||
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
|
||||
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
|
||||
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
|
||||
updateExampleIndex := 0
|
||||
DeclareLeafMetadata(updateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "whiteboard",
|
||||
Name: "update",
|
||||
CanonicalPath: "whiteboard.update",
|
||||
CLIPath: "whiteboard update",
|
||||
PrimaryCLIPath: "whiteboard update",
|
||||
},
|
||||
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
|
||||
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
|
||||
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
|
||||
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
|
||||
ExampleDispositions: []contract.ExampleDisposition{{
|
||||
Index: &updateExampleIndex,
|
||||
Mode: contract.ExampleDispositionModeContractOnly,
|
||||
ReasonCode: contract.ExampleDispositionReasonLocalState,
|
||||
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
|
||||
Reviewed: true,
|
||||
}},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
|
||||
{Name: "source", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(queryCmd, updateCmd)
|
||||
return root
|
||||
}
|
||||
|
||||
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
|
||||
for _, name := range []string{"jq", "fields"} {
|
||||
flag := cmd.Flags().Lookup(name)
|
||||
if flag == nil {
|
||||
flag = cmd.InheritedFlags().Lookup(name)
|
||||
}
|
||||
if flag != nil && flag.Changed {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
|
||||
Suggestion: "直接读取命令返回的结构化 JSON",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
code := CodeInvalidPath
|
||||
if os.IsNotExist(err) {
|
||||
code = CodeFileNotFound
|
||||
}
|
||||
return nil, "", &CLIError{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
|
||||
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
var input whiteboardUpdateFile
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&input); err != nil {
|
||||
return nil, "", invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
|
||||
return nil, "", invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
if input.Source == nil {
|
||||
return nil, "", invalidWhiteboardSourceParam("source is required")
|
||||
}
|
||||
if input.Source.SchemaVersion != "1.0" {
|
||||
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
|
||||
}
|
||||
if input.Source.CatalogVersion != "dml-v1" {
|
||||
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
|
||||
}
|
||||
|
||||
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !input.Overwrite && nodeCount == 0 {
|
||||
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
|
||||
}
|
||||
return &input, nodesJSON, nil
|
||||
}
|
||||
|
||||
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return fmt.Errorf("multiple JSON values are not allowed")
|
||||
} else if !errors.Is(err, io.EOF) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
|
||||
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
|
||||
}
|
||||
|
||||
var nodes []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &nodes); err != nil {
|
||||
return "", 0, invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
for i, node := range nodes {
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(node, &object); err != nil || object == nil {
|
||||
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
|
||||
}
|
||||
}
|
||||
|
||||
var compact bytes.Buffer
|
||||
if err := compactWhiteboardJSON(&compact, raw); err != nil {
|
||||
return "", 0, invalidWhiteboardSourceJSON(err)
|
||||
}
|
||||
return compact.String(), len(nodes), nil
|
||||
}
|
||||
|
||||
func invalidWhiteboardSourceJSON(err error) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidJSON,
|
||||
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
|
||||
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
func invalidWhiteboardSourceParam(message string) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: message,
|
||||
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
|
||||
}
|
||||
}
|
||||
|
||||
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer(whiteboardServerID, toolName, args)
|
||||
}
|
||||
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var response map[string]any
|
||||
decoder := json.NewDecoder(strings.NewReader(text))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, err)
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, err)
|
||||
}
|
||||
if response == nil {
|
||||
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
|
||||
}
|
||||
|
||||
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
|
||||
var result any
|
||||
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
|
||||
resultDecoder.UseNumber()
|
||||
if err := resultDecoder.Decode(&result); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
|
||||
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
|
||||
}
|
||||
response["resultJson"] = result
|
||||
}
|
||||
return deps.Out.PrintJSON(response)
|
||||
}
|
||||
|
||||
func invalidWhiteboardToolResult(toolName string, err error) error {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "白板服务返回了无法解析的 JSON",
|
||||
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
|
||||
Operation: whiteboardServerID + "/" + toolName,
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
|
||||
previousMarshal := whiteboardJSONMarshal
|
||||
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
|
||||
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
whiteboardJSONMarshal = previousMarshal
|
||||
|
||||
previousPrepare := prepareWhiteboardCard
|
||||
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
|
||||
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
|
||||
caller := &whiteboardTestCaller{}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
previousCompact := compactWhiteboardJSON
|
||||
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
|
||||
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
|
||||
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
|
||||
t.Fatal("expected compact error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocWhiteboardInsertDryRun(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{dry: true}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "n"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeWhiteboardFixture(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "whiteboard.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "invalid json", content: `{`},
|
||||
{name: "trailing value", content: `{}` + ` {}`},
|
||||
{name: "missing source", content: `{}`},
|
||||
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
|
||||
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
|
||||
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
|
||||
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
|
||||
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
|
||||
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
|
||||
{name: "unknown field", content: `{"unknown":true}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
|
||||
t.Fatal("expected missing-file error")
|
||||
}
|
||||
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
|
||||
t.Fatal("expected directory read error")
|
||||
}
|
||||
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
|
||||
t.Fatal("expected malformed nodes array error")
|
||||
}
|
||||
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
|
||||
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
|
||||
if err != nil || !input.Overwrite || nodes != "[]" {
|
||||
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
|
||||
for _, name := range []string{"jq", "fields"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(name, "", "")
|
||||
if err := cmd.Flags().Set(name, ".result"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
|
||||
t.Fatal("expected rejected output filter")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
responses := []string{
|
||||
`{`,
|
||||
`{} {}`,
|
||||
`null`,
|
||||
`{"resultJson":"{"}`,
|
||||
`{"resultJson":"{} {}"}`,
|
||||
}
|
||||
for _, response := range responses {
|
||||
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
|
||||
t.Fatalf("response %q should fail", response)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &whiteboardTestCaller{dry: true}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
attrs bool
|
||||
}{
|
||||
{name: "invalid response", response: `{`},
|
||||
{name: "missing block", response: `{"blocks":[]}`},
|
||||
{name: "non object block", response: `{"blocks":[1]}`},
|
||||
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
|
||||
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
|
||||
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
var err error
|
||||
if test.attrs {
|
||||
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
|
||||
} else {
|
||||
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected query validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected caller error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardCommandValidationBranches(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
for _, args := range [][]string{
|
||||
{"query", "--node", "n"},
|
||||
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
|
||||
{"update", "--node", "n", "--part-id", "p"},
|
||||
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
|
||||
} {
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.PersistentFlags().String("jq", "", "")
|
||||
cmd.PersistentFlags().String("fields", "", "")
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatalf("args %v should fail", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
|
||||
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls[0].args["mode"] != "overwrite" {
|
||||
t.Fatalf("args=%#v", caller.calls[0].args)
|
||||
}
|
||||
|
||||
cmd = newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected source error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
|
||||
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
|
||||
}}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
previousPut := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
t.Cleanup(func() { httpPutFile = previousPut })
|
||||
file := writeWhiteboardFixture(t, "svg")
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
|
||||
t.Fatalf("args=%#v", caller.calls[0].args)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"media", "upload", "--node", "n"},
|
||||
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
|
||||
{"media", "upload", "--node", "n", "--file", t.TempDir()},
|
||||
} {
|
||||
cmd = newDocCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatalf("args %v should fail", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadRemainingBranches(t *testing.T) {
|
||||
file := writeWhiteboardFixture(t, "svg")
|
||||
|
||||
caller := &whiteboardTestCaller{dry: true}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
caller *whiteboardTestCaller
|
||||
response string
|
||||
}{
|
||||
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
|
||||
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
|
||||
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
|
||||
}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
installWhiteboardTestCaller(t, test.caller)
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected upload error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocWhiteboardInsertCallerError(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
|
||||
if index == 0 {
|
||||
return errors.New("insert")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected insert error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
|
||||
if got := extractWhiteboardID(nil); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
|
||||
t.Fatal("expected trailing token error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type whiteboardTestCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type whiteboardTestCaller struct {
|
||||
dry bool
|
||||
format string
|
||||
err func(whiteboardTestCall, int) error
|
||||
response func(whiteboardTestCall, int) string
|
||||
calls []whiteboardTestCall
|
||||
}
|
||||
|
||||
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
call := whiteboardTestCall{server: server, tool: tool, args: args}
|
||||
c.calls = append(c.calls, call)
|
||||
if c.err != nil {
|
||||
if err := c.err(call, len(c.calls)-1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
text := `{}`
|
||||
if c.response != nil {
|
||||
text = c.response(call, len(c.calls)-1)
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (c *whiteboardTestCaller) Format() string { return c.format }
|
||||
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
|
||||
func (*whiteboardTestCaller) Fields() string { return "" }
|
||||
func (*whiteboardTestCaller) JQ() string { return "" }
|
||||
|
||||
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
output := &bytes.Buffer{}
|
||||
deps.Out.w = output
|
||||
deps.Out.errW = &bytes.Buffer{}
|
||||
return output
|
||||
}
|
||||
|
||||
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(whiteboardTestCall, int) string {
|
||||
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
|
||||
t.Fatalf("args = %#v", caller.calls[0].args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
if _, ok := payload["resultJson"].(map[string]any); !ok {
|
||||
t.Fatalf("resultJson was not decoded: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "whiteboard.json")
|
||||
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
|
||||
cmd := newWhiteboardCommand()
|
||||
cmd.SetIn(strings.NewReader("no\n"))
|
||||
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
|
||||
t.Fatalf("err = %v, want cancellation", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
|
||||
}
|
||||
|
||||
cmd = newWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
|
||||
t.Fatalf("args = %#v", caller.calls[0].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
|
||||
var blockID string
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(call whiteboardTestCall, index int) string {
|
||||
if index == 0 {
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
|
||||
t.Fatalf("jsonml: %v", err)
|
||||
}
|
||||
attrs := node[1].(map[string]any)
|
||||
blockID = attrs["uuid"].(string)
|
||||
return `{}`
|
||||
}
|
||||
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
|
||||
encoded, _ := json.Marshal(jsonml)
|
||||
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
previousDelays := whiteboardRetryDelays
|
||||
whiteboardRetryDelays = nil
|
||||
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
|
||||
t.Fatalf("unexpected servers: %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["whiteboardId"] != "part-real" {
|
||||
t.Fatalf("output = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
|
||||
// 让回查桩可以用真实块 ID 组装响应。
|
||||
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
|
||||
t.Helper()
|
||||
raw, _ := call.args["jsonml"].(string)
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(raw), &node); err != nil {
|
||||
t.Fatalf("jsonml: %v", err)
|
||||
}
|
||||
if len(node) < 2 {
|
||||
t.Fatalf("jsonml node missing attrs: %q", raw)
|
||||
}
|
||||
attrs, _ := node[1].(map[string]any)
|
||||
id, _ := attrs["uuid"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("jsonml node missing uuid: %q", raw)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
|
||||
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
|
||||
t.Helper()
|
||||
previousDelays := whiteboardRetryDelays
|
||||
previousSleep := whiteboardSleep
|
||||
stub := make([]time.Duration, delays)
|
||||
for i := range stub {
|
||||
stub[i] = time.Millisecond
|
||||
}
|
||||
slept := 0
|
||||
whiteboardRetryDelays = stub
|
||||
whiteboardSleep = func(time.Duration) { slept++ }
|
||||
t.Cleanup(func() {
|
||||
whiteboardRetryDelays = previousDelays
|
||||
whiteboardSleep = previousSleep
|
||||
})
|
||||
return func() int { return slept }
|
||||
}
|
||||
|
||||
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
|
||||
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
|
||||
// 继续带着空 partId 调用 whiteboard query/update。
|
||||
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
queryErr error
|
||||
queryBody func(blockID string) string
|
||||
}{
|
||||
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
|
||||
{
|
||||
name: "response missing blocks field",
|
||||
queryBody: func(string) string { return `{"success":true}` },
|
||||
},
|
||||
{
|
||||
name: "blocks field is not an array",
|
||||
queryBody: func(string) string { return `{"blocks":{}}` },
|
||||
},
|
||||
{
|
||||
name: "block jsonml unparsable",
|
||||
queryBody: func(blockID string) string {
|
||||
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "card node without attrs",
|
||||
queryBody: func(blockID string) string {
|
||||
encoded, _ := json.Marshal(`[]`)
|
||||
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
blockID := ""
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
caller.response = func(call whiteboardTestCall, index int) string {
|
||||
if index == 0 {
|
||||
blockID = whiteboardCardBlockID(t, call)
|
||||
return `{}`
|
||||
}
|
||||
if test.queryBody == nil {
|
||||
return `{}`
|
||||
}
|
||||
return test.queryBody(blockID)
|
||||
}
|
||||
if test.queryErr != nil {
|
||||
caller.err = func(_ whiteboardTestCall, index int) error {
|
||||
if index == 0 {
|
||||
return nil
|
||||
}
|
||||
return test.queryErr
|
||||
}
|
||||
}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
slept := stubWhiteboardRetries(t, 2)
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
|
||||
t.Fatalf("err = %v, want fail-closed verification error", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), blockID) {
|
||||
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
|
||||
}
|
||||
if len(caller.calls) != 2 || slept() != 0 {
|
||||
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
|
||||
len(caller.calls), slept())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
|
||||
// whiteboardId 为 null。
|
||||
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(_ whiteboardTestCall, index int) string {
|
||||
if index == 0 {
|
||||
return `{}`
|
||||
}
|
||||
return `{"blocks":[]}`
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
slept := stubWhiteboardRetries(t, 2)
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("block-not-visible must stay a soft success: %v", err)
|
||||
}
|
||||
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
|
||||
if len(caller.calls) != 4 || slept() != 2 {
|
||||
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
whiteboardID, present := result["whiteboardId"]
|
||||
if payload["success"] != true || !present || whiteboardID != nil {
|
||||
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
|
||||
}
|
||||
if result["blockId"] == "" || result["blockId"] == nil {
|
||||
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
|
||||
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
|
||||
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "ref-block with parent-block",
|
||||
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
|
||||
},
|
||||
{
|
||||
name: "where with parent-block",
|
||||
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &whiteboardTestCaller{format: "json"}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
|
||||
cmd := newDocWhiteboardCommand()
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs(test.args)
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "icon.svg")
|
||||
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(whiteboardTestCall, int) string {
|
||||
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
|
||||
},
|
||||
}
|
||||
output := installWhiteboardTestCaller(t, caller)
|
||||
previousPut := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
t.Cleanup(func() { httpPutFile = previousPut })
|
||||
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output = %q: %v", output.String(), err)
|
||||
}
|
||||
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
|
||||
t.Fatalf("output = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "icon.svg")
|
||||
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploadURL := "https://upload.example.test/secret-token"
|
||||
caller := &whiteboardTestCaller{
|
||||
format: "json",
|
||||
response: func(whiteboardTestCall, int) string {
|
||||
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
|
||||
},
|
||||
}
|
||||
installWhiteboardTestCaller(t, caller)
|
||||
previousPut := httpPutFile
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
|
||||
return fmt.Errorf("PUT %s: connection reset", uploadURL)
|
||||
}
|
||||
t.Cleanup(func() { httpPutFile = previousPut })
|
||||
|
||||
cmd := newDocCommand()
|
||||
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
|
||||
t.Fatalf("err = %v, want redacted temporary upload URL", err)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,11 @@ func openSupplementServers() []ServerInfo {
|
||||
Name: "MCP 元服务",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/89833ea5debf30c260a07ffcb5127ffa3bf0c830cd76babadb293d9861485d44",
|
||||
},
|
||||
{
|
||||
ID: "whiteboard",
|
||||
Name: "钉钉白板",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,17 +105,27 @@ func TestOpenVisibleProductsExcludesCompatibilityOnlyCommands(t *testing.T) {
|
||||
|
||||
func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
|
||||
servers := openSupplementServers()
|
||||
foundMCPMeta := false
|
||||
foundWhiteboard := false
|
||||
for _, server := range servers {
|
||||
if server.ID == "whiteboard" {
|
||||
foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard"
|
||||
}
|
||||
if server.ID != "mcp-meta" {
|
||||
continue
|
||||
}
|
||||
foundMCPMeta = true
|
||||
if server.Endpoint == "" {
|
||||
t.Fatal("mcp-meta has empty endpoint")
|
||||
}
|
||||
if len(server.Prefixes) != 0 {
|
||||
t.Fatal("mcp-meta must remain helper-only without command prefixes")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("openSupplementServers() missing mcp-meta")
|
||||
if !foundMCPMeta {
|
||||
t.Fatal("openSupplementServers() missing mcp-meta")
|
||||
}
|
||||
if !foundWhiteboard {
|
||||
t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ cli_version: ">=1.0.15"
|
||||
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) |
|
||||
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
|
||||
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
|
||||
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
@@ -120,6 +121,7 @@ cli_version: ">=1.0.15"
|
||||
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
|
||||
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
|
||||
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
|
||||
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
|
||||
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
|
||||
|
||||
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume <event_key> [event_key...] --flatten`。
|
||||
|
||||
@@ -1071,6 +1071,19 @@ EOF
|
||||
- `comment create` 是全文评论;`comment create-inline` 是划词评论,必须先 `block list` 拿到 `blockId` 并确定 `--start` / `--end` 偏移(按块内纯文本字符算,从 0 开始)
|
||||
- 全文评论 `create` / `reply` / `update` 支持通过 `--mentioned-open-conversation-id` @群;划词评论 `create-inline` 不支持 @群
|
||||
|
||||
## 白板卡片与白板媒体资源
|
||||
|
||||
```bash
|
||||
dws doc whiteboard insert --node <DOC_ID> --yes --format json
|
||||
dws doc media upload --node <DOC_ID> --file ./icon.svg \
|
||||
--mime-type image/svg+xml --yes --format json
|
||||
```
|
||||
|
||||
两个命令都是远端写入,必须先获得用户确认。insert 返回的 `whiteboardId` 是
|
||||
`dws whiteboard query/update` 使用的 partId;`blockId` 只用于文档块定位/删除。
|
||||
media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板
|
||||
Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
| 脚本 | 场景 | 用法 |
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 钉钉文档内嵌白板
|
||||
|
||||
`dws whiteboard` 读取和更新已存在于在线文档中的单页白板。创建白板卡片使用
|
||||
`dws doc whiteboard insert`;删除卡片使用已有的 `dws doc block delete`。
|
||||
|
||||
OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需读取
|
||||
[协议索引](./whiteboard/open-nodes-v1.md);不要根据本页概要猜测节点字段或
|
||||
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
|
||||
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
|
||||
|
||||
## 标准流程
|
||||
|
||||
1. 从用户输入或真实文档 JSONML 取得 `nodeId` 和 card `metadata.id`(partId)。
|
||||
2. `dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json` 保存当前内容。
|
||||
3. 生成 OpenNodes V1 文件;不能把 query 响应直接回写。
|
||||
4. 向用户展示写入范围并取得确认。
|
||||
5. `dws whiteboard update --node <DOC_ID> --part-id <PART_ID> --source <FILE> --yes --format json`。
|
||||
6. 再次 query 验证节点、层级和连接关系。
|
||||
|
||||
更新文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"type": "text",
|
||||
"x": 40,
|
||||
"y": 40,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"runs": [{"text": "方案"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- append (`overwrite=false`) 至少包含一个节点。
|
||||
- overwrite (`true`) 整页重建并允许空数组;必须先备份当前 query 结果。
|
||||
- 所有 update 都要求用户确认和 `--yes`。
|
||||
- 当前只支持单页,不支持 `pageId`,也不支持使用真实节点 ID 做局部更新。
|
||||
- `--jq` / `--fields` 不适用于白板命令。
|
||||
|
||||
## 创建白板
|
||||
|
||||
```bash
|
||||
dws doc whiteboard insert --node <DOC_ID> --yes --format json
|
||||
```
|
||||
|
||||
返回的 `whiteboardId` 是 partId,`blockId` 是文档块 ID。删除卡片走:
|
||||
|
||||
```bash
|
||||
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes --format json
|
||||
```
|
||||
|
||||
## Vector / SVG
|
||||
|
||||
先上传绑定到同一 nodeId 的媒体资源:
|
||||
|
||||
```bash
|
||||
dws doc media upload --node <DOC_ID> --file ./icon.svg \
|
||||
--mime-type image/svg+xml --yes --format json
|
||||
```
|
||||
|
||||
只使用稳定输出 `resourceId` / `resourceUrl`,不要使用临时 uploadUrl、本地路径或
|
||||
跨 nodeId 资源。
|
||||
@@ -0,0 +1,45 @@
|
||||
# OpenNodes V1(DWS 白板协议索引)
|
||||
|
||||
本目录承载 `dws whiteboard query/update` 使用的 OpenNodes V1 协议。协议按
|
||||
调用阶段拆分,Agent 只加载当前任务所需章节,避免一次性读取全部内容。
|
||||
|
||||
## DWS 使用规则
|
||||
|
||||
- 只通过 `dws whiteboard query/update` 读写白板。
|
||||
- 每次调用必须提供承载白板的文档 `--node` 和目标白板 `--part-id`。
|
||||
- DWS 当前只支持单页白板:命令没有 `--page-id`,update 文件禁止包含
|
||||
`pageId`。
|
||||
- `query` 不接收请求体;CLI 会把返回的 `resultJson` 解析成对象。
|
||||
- 白板命令不支持使用全局 `--jq` 或 `--fields` 过滤输出;传入任一参数都会报错,
|
||||
Agent 直接读取 CLI 返回的结构化 JSON。
|
||||
- `update --source` 使用 `overwrite + source` 信封;append 和 overwrite 都是
|
||||
远端写入,获得用户确认后必须通过 `--yes` 显式确认。
|
||||
- CLI 只预检 JSON、信封、版本和 `nodes` 数组等外层结构;节点字段、枚举、层级、
|
||||
引用和业务约束由白板服务完整校验。任一层失败都不会保留部分更新。
|
||||
- DWS 返回以 `success`、`nodeId`、`partId`、`resultJson` 和可选的
|
||||
`resultSummary` 为准。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 当前任务 | 必读章节 |
|
||||
|---|---|
|
||||
| 理解版本、兼容和命令语义 | [01-overview](open-nodes-v1/01-overview.md) |
|
||||
| 读取或解释 query 结果 | [02-query](open-nodes-v1/02-query.md) |
|
||||
| 构造 append、overwrite 或清空请求 | [03-update](open-nodes-v1/03-update.md) |
|
||||
| 写富文本、列表、链接、主题色、渐变或阴影 | [04-text-style](open-nodes-v1/04-text-style.md) |
|
||||
| 写 shape、便签、frame、group 或 connector | [05-shape-frame-group-connector](open-nodes-v1/05-shape-frame-group-connector.md) |
|
||||
| 写已上传 Vector、内置 Icon 或自由 Path | [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md) |
|
||||
| 处理错误、query 转写或判断 writeSupport | [07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md) |
|
||||
| 选择合法 geometry 或 icon catalogId | [08-catalogs](open-nodes-v1/08-catalogs.md) |
|
||||
|
||||
## 强制读取规则
|
||||
|
||||
- 使用 `shape.geometry` 前必须读取 [08-catalogs](open-nodes-v1/08-catalogs.md),
|
||||
不得猜测 geometry。
|
||||
- 使用 `icon.catalogId` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md)
|
||||
和 [08-catalogs](open-nodes-v1/08-catalogs.md)。
|
||||
- 使用 `path` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md),
|
||||
不得把它当作通用 SVG Path。
|
||||
- Query 结果不能直接作为 update source;转换前必须读取
|
||||
[03-update](open-nodes-v1/03-update.md) 和
|
||||
[07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md)。
|
||||
@@ -0,0 +1,57 @@
|
||||
# DWS OpenNodes V1 协议说明
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
> 协议版本:`schemaVersion = "1.0"`,`catalogVersion = "dml-v1"`。
|
||||
|
||||
## 1. 协议用途
|
||||
|
||||
OpenNodes 是 DWS 白板命令使用的语义节点协议,提供两类能力:
|
||||
|
||||
- `dws whiteboard query`:返回稳定、可理解的页面和节点数据。
|
||||
- `dws whiteboard update`:接收受约束的节点描述,以 `append` 或
|
||||
`overwrite` 模式修改白板。
|
||||
|
||||
调用方只应依赖本文声明的语义字段和行为:
|
||||
|
||||
- `query` 不修改白板。
|
||||
- `update` 全部成功或全部回滚,不返回中间状态。
|
||||
- 未声明的存储字段、类型名称和处理过程不属于协议承诺。
|
||||
|
||||
OpenNodes V1 支持的节点类型、字段和读写范围见第 7 节。
|
||||
|
||||
DWS 负责身份认证和权限校验。Vector 资源准备使用 `dws doc media upload`,
|
||||
具体流程见白板命令参考。
|
||||
|
||||
## 2. 版本与兼容原则
|
||||
|
||||
| 字段 | 当前值 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `schemaVersion` | `1.0` | 控制文档结构、节点字段和字段语义。 |
|
||||
| `catalogVersion` | `dml-v1` | 控制允许写入的 DML 几何、连接线标记和内置 icon 目录。 |
|
||||
|
||||
V1 采用严格校验:
|
||||
|
||||
- 必填字段缺失会失败。
|
||||
- 未声明字段会失败,不会被静默忽略。
|
||||
- query-only 字段出现在 update 中会以 `readOnlyField` 失败。
|
||||
- 不支持的节点类型、目录值或引用范围会失败。
|
||||
- `null` 不代表“使用默认值”;除非字段类型明确允许,否则会失败。
|
||||
|
||||
本文列出的请求枚举值都是协议字面量,调用方必须按文档中的大小写和拼写原样传入,
|
||||
不能自行转换或猜测。响应中未来可能增加可选字段,调用方应忽略不认识的响应字段。
|
||||
|
||||
调用方必须原样携带当前版本值。新增不兼容结构时应升级
|
||||
`schemaVersion`;修改 DML、marker 或 icon 目录时应评估并升级
|
||||
`catalogVersion`。
|
||||
|
||||
## 3. DWS 命令一览
|
||||
|
||||
| 命令 | 所需权限 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| `dws whiteboard query --node ... --part-id ...` | 可查看白板 | 读取单页白板,不修改内容。 |
|
||||
| `dws whiteboard update --node ... --part-id ... --source ... --yes` | 可编辑白板 | 追加节点或整页重建;所有更新都需先取得用户确认。 |
|
||||
|
||||
DWS 当前只支持文字文档中已有的单页内嵌白板。命令不接收 `pageId`,也不提供
|
||||
创建页面、切换页面或按既有节点 ID 局部修改的能力。
|
||||
@@ -0,0 +1,288 @@
|
||||
# OpenNodes V1 — Query 请求、返回结构和节点公共字段
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 4. Query 协议
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```bash
|
||||
dws whiteboard query \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--format json
|
||||
```
|
||||
|
||||
`query` 不接收请求体或 `pageId`。`--node` 和 `--part-id` 的发现规则见
|
||||
[白板命令参考](../../whiteboard.md)。CLI 会把服务返回的 `resultJson` JSON
|
||||
字符串解析成对象。
|
||||
|
||||
### 4.2 返回结构
|
||||
|
||||
```ts
|
||||
interface OpenNodesDocument {
|
||||
schemaVersion: "1.0";
|
||||
catalogVersion: "dml-v1";
|
||||
pages: OpenPage[];
|
||||
}
|
||||
|
||||
interface OpenPage {
|
||||
id: string;
|
||||
nodes: OpenNode[];
|
||||
}
|
||||
```
|
||||
|
||||
DWS 当前只支持单页白板,因此 `pages` 固定包含一个页面。调用方仍应从返回值读取
|
||||
页面 `id`,但不能把它作为 `pageId` 传给 DWS 命令。
|
||||
|
||||
母版节点不会作为独立页面返回,而会合并到引用它的页面 `nodes` 中,并带有:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "master",
|
||||
"writeSupport": "readOnly",
|
||||
"unsupportedFeatures": ["node.source.master"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 节点公共字段
|
||||
|
||||
`type` 的完整公开枚举如下。前一组可由 update 创建,后一组仅供 query 返回:
|
||||
|
||||
```ts
|
||||
type WritableOpenNodeType =
|
||||
| "shape"
|
||||
| "text"
|
||||
| "connector"
|
||||
| "stickyNote"
|
||||
| "frame"
|
||||
| "group"
|
||||
| "vector"
|
||||
| "icon"
|
||||
| "path";
|
||||
|
||||
type ReadOnlyOpenNodeType =
|
||||
| "image"
|
||||
| "pdf"
|
||||
| "media"
|
||||
| "webLink"
|
||||
| "table"
|
||||
| "chart"
|
||||
| "uml"
|
||||
| "swimlane"
|
||||
| "mind"
|
||||
| "timer"
|
||||
| "placeholder"
|
||||
| "unknown";
|
||||
|
||||
type OpenNodeType = WritableOpenNodeType | ReadOnlyOpenNodeType;
|
||||
```
|
||||
|
||||
每个 query 节点都包含以下公共字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | 白板中真实、稳定的节点 ID。 |
|
||||
| `type` | `OpenNodeType` | OpenNodes 公开语义类型,取值见上方枚举。 |
|
||||
| `parentId` | `string?` | group/frame 父节点 ID。无父节点时省略。 |
|
||||
| `children` | `string[]?` | group/frame 的直接子节点 ID,由服务端推导。 |
|
||||
| `x`、`y` | `number` | 有父节点时相对父节点;否则相对页面。单位为 px。 |
|
||||
| `width`、`height` | `number` | 节点包围盒尺寸,单位为 px。连接线允许其中一个为 `0`。 |
|
||||
| `angle` | `number` | 归一化到 `[0, 360)` 的角度。 |
|
||||
| `absoluteBounds` | `OpenBounds` | 页面坐标系中的绝对包围盒。 |
|
||||
| `layer` | `background \| normal \| foreground` | 节点所在层。 |
|
||||
| `zIndex` | `number` | 同一父节点、同一 layer 内的非负顺序,值越小越靠后。 |
|
||||
| `hidden` | `boolean` | 节点是否隐藏。 |
|
||||
| `locked` | `boolean` | 节点是否锁定。 |
|
||||
| `source` | `page \| master` | 节点来自当前页面还是母版。 |
|
||||
| `writeSupport` | `readWrite \| readOnly` | 当前节点能否由 V1 update 表达。 |
|
||||
| `unsupportedFeatures` | `string[]?` | 只读原因;`readWrite` 节点省略。 |
|
||||
|
||||
公共结构和各节点分支定义如下;分支中的字段含义与写入限制见第 7 节:
|
||||
|
||||
```ts
|
||||
interface OpenPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface OpenBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
interface OpenNodeBase {
|
||||
id: string;
|
||||
type: OpenNodeType;
|
||||
parentId?: string;
|
||||
children?: string[];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
absoluteBounds: OpenBounds;
|
||||
layer: "background" | "normal" | "foreground";
|
||||
zIndex: number;
|
||||
hidden: boolean;
|
||||
locked: boolean;
|
||||
source: "page" | "master";
|
||||
writeSupport: "readWrite" | "readOnly";
|
||||
unsupportedFeatures?: string[];
|
||||
}
|
||||
|
||||
interface OpenShapeNode extends OpenNodeBase {
|
||||
type: "shape";
|
||||
geometry: `dml:${string}`;
|
||||
adjustments?: Record<string, number>;
|
||||
text?: OpenText;
|
||||
style?: OpenNodeStyle;
|
||||
}
|
||||
|
||||
interface OpenTextNode extends OpenNodeBase {
|
||||
type: "text";
|
||||
text: OpenText;
|
||||
style?: OpenNodeStyle;
|
||||
}
|
||||
|
||||
interface OpenConnectorNode extends OpenNodeBase {
|
||||
type: "connector";
|
||||
start: OpenConnectorEndpoint;
|
||||
end: OpenConnectorEndpoint;
|
||||
routing: OpenConnectorRouting;
|
||||
waypoints?: OpenPoint[];
|
||||
style?: OpenNodeStyle;
|
||||
resolvedPath: OpenResolvedConnectorPath;
|
||||
}
|
||||
|
||||
interface OpenStickyNoteNode extends OpenNodeBase {
|
||||
type: "stickyNote";
|
||||
text?: OpenText;
|
||||
style?: OpenNodeStyle;
|
||||
creator?: {
|
||||
displayName?: string;
|
||||
hasAvatar?: boolean;
|
||||
};
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
background: OpenPaint;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface OpenFrameNode extends OpenNodeBase {
|
||||
type: "frame";
|
||||
title?: {
|
||||
text: OpenText;
|
||||
box: { width: number; height: number };
|
||||
};
|
||||
style?: OpenNodeStyle;
|
||||
presentationOrder?: number;
|
||||
resizeMode: "free" | "fixedAspectRatio";
|
||||
}
|
||||
|
||||
interface OpenGroupNode extends OpenNodeBase {
|
||||
type: "group";
|
||||
children: string[];
|
||||
}
|
||||
|
||||
interface OpenVectorNode extends OpenNodeBase {
|
||||
type: "vector";
|
||||
resource: OpenVectorResource;
|
||||
}
|
||||
|
||||
interface OpenIconNode extends OpenNodeBase {
|
||||
type: "icon";
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
interface OpenPathNode extends OpenNodeBase {
|
||||
type: "path";
|
||||
path: OpenPathData;
|
||||
style?: OpenNodeStyle;
|
||||
}
|
||||
|
||||
interface OpenReadOnlyNode extends OpenNodeBase {
|
||||
type: ReadOnlyOpenNodeType;
|
||||
writeSupport: "readOnly";
|
||||
unsupportedFeatures: string[];
|
||||
}
|
||||
|
||||
type OpenNode =
|
||||
| OpenShapeNode
|
||||
| OpenTextNode
|
||||
| OpenConnectorNode
|
||||
| OpenStickyNoteNode
|
||||
| OpenFrameNode
|
||||
| OpenGroupNode
|
||||
| OpenVectorNode
|
||||
| OpenIconNode
|
||||
| OpenPathNode
|
||||
| OpenReadOnlyNode;
|
||||
```
|
||||
|
||||
query 中 `icon.catalogId` 使用 `string`,是为了让无法映射到当前目录的既有图标仍可
|
||||
被诊断;update 只能传入 7.10 节 `OpenIconCatalogId` 中列出的值。
|
||||
|
||||
### 4.4 Query 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"pages": [
|
||||
{
|
||||
"id": "page",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "real-node-id",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"angle": 0,
|
||||
"absoluteBounds": {
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"angle": 0
|
||||
},
|
||||
"layer": "normal",
|
||||
"zIndex": 0,
|
||||
"hidden": false,
|
||||
"locked": false,
|
||||
"source": "page",
|
||||
"writeSupport": "readWrite",
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "left",
|
||||
"runs": [
|
||||
{
|
||||
"text": "Hello OpenNodes",
|
||||
"marks": {
|
||||
"fontSize": 16,
|
||||
"color": "#223344"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center",
|
||||
"padding": [2, 4],
|
||||
"plainText": "Hello OpenNodes",
|
||||
"writeSupport": "readWrite"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,249 @@
|
||||
# OpenNodes V1 — Update 信封、Append/Overwrite 和公共写入字段
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 5. Update 协议
|
||||
|
||||
### 5.1 请求信封
|
||||
|
||||
```ts
|
||||
interface OpenNodesUpdateRequest {
|
||||
overwrite?: boolean;
|
||||
source: {
|
||||
schemaVersion: "1.0";
|
||||
catalogVersion: "dml-v1";
|
||||
nodes: OpenNodeWrite[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
字段含义:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `overwrite` | 否 | `false` 或省略为 append;`true` 为 overwrite。 |
|
||||
| `source.schemaVersion` | 是 | 必须为 `1.0`。 |
|
||||
| `source.catalogVersion` | 是 | 必须为 `dml-v1`。 |
|
||||
| `source.nodes` | 是 | 本次创建的节点数组。append 至少一个;overwrite 允许空数组。 |
|
||||
|
||||
`source` 不能直接使用 query 返回的 `OpenNodesDocument`,也不接受 `pages`。
|
||||
DWS 不接受 `pageId`;传入会在 CLI 本地校验阶段失败。
|
||||
|
||||
V1 没有“按真实节点 ID patch 既有节点”的语义。`source.nodes` 中的每一项都会
|
||||
创建一个新节点,`id` 仅是本次请求内建立父子关系和连接线引用的临时 ID:
|
||||
append 是新增节点,overwrite 是整页删除后重新创建。
|
||||
|
||||
### 5.2 Append 与 Overwrite
|
||||
|
||||
> **注意:** `overwrite: true` 是破坏性操作,会删除当前白板页面的全部自有
|
||||
> 节点。调用前应先 query 并确认影响范围;`nodes: []` 会清空页面。不希望删除
|
||||
> 既有内容时,应使用 append。
|
||||
|
||||
| 行为 | append | overwrite |
|
||||
| --- | --- | --- |
|
||||
| `overwrite` | `false` 或省略 | `true` |
|
||||
| 空 `nodes` | 禁止 | 允许,用于清空当前页面 |
|
||||
| 当前页面旧节点 | 全部保留 | 删除页面自有节点后创建新节点 |
|
||||
| 母版节点 | 保留 | 保留 |
|
||||
| 页面级设置 | 保留 | 保留 |
|
||||
| 原子性 | 全部成功或全部回滚 | 全部成功或全部回滚 |
|
||||
|
||||
overwrite 会替换当前页面的全部自有节点,不要求旧节点本身可由 OpenNodes V1
|
||||
写入。因此页面中存在 image、PDF、复杂文本等只读节点,不会单独阻止清空页面。
|
||||
|
||||
overwrite 会在删除前执行安全预检;以下情况会拒绝执行:
|
||||
|
||||
- 节点被锁定:`lockedNode`。
|
||||
- 节点不允许被删除:`deleteForbidden`。
|
||||
- 页面包含无法安全保留或清理的关联数据:`unknownMetadataReference`。
|
||||
- 目标节点未能完整删除:`deleteFailed`。
|
||||
|
||||
与被删除节点绑定且有明确清理规则的关联数据会随节点清理;无法安全保留或清理的
|
||||
关联数据会使 overwrite 失败。
|
||||
|
||||
overwrite 只替换当前页面节点,不会重建页面,也不会修改主题等页面级设置。
|
||||
白板已有的主题会被保留;白板没有有效主题时也不会自动添加。调用方应使用
|
||||
已配置有效主题的白板,或者显式提供不依赖主题的颜色。
|
||||
|
||||
### 5.3 成功结果
|
||||
|
||||
```ts
|
||||
interface DWSWhiteboardUpdateResponse {
|
||||
success: true;
|
||||
nodeId: string;
|
||||
partId: string;
|
||||
resultJson: {
|
||||
mode: "append" | "overwrite";
|
||||
createdNodeIds: string[];
|
||||
idMap: Record<string, string>;
|
||||
deletedNodeCount: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `success` | `true` 表示本次 DWS 调用成功。 |
|
||||
| `nodeId` | 输入的文档节点 ID。 |
|
||||
| `partId` | 输入的白板标识。 |
|
||||
| `resultJson.mode` | 实际执行的模式。 |
|
||||
| `resultJson.createdNodeIds` | 按请求节点顺序返回真实节点 ID。 |
|
||||
| `resultJson.idMap` | 请求中显式临时 ID 到真实节点 ID 的映射。 |
|
||||
| `resultJson.deletedNodeCount` | append 恒为 `0`;overwrite 为删除的页面自有节点数。 |
|
||||
| `resultJson.message` | 供人阅读的结果摘要,不应作为机器判断依据。 |
|
||||
|
||||
响应可能增加其他可选字段;Agent 不应依赖本节未声明的字段。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"nodeId": "DOC_NODE_ID",
|
||||
"partId": "WHITEBOARD_PART_ID",
|
||||
"resultJson": {
|
||||
"mode": "append",
|
||||
"createdNodeIds": ["generated-title-id", "generated-body-id"],
|
||||
"idMap": {
|
||||
"title": "generated-title-id",
|
||||
"body": "generated-body-id"
|
||||
},
|
||||
"deletedNodeCount": 0,
|
||||
"message": "Created 2 Whiteboard nodes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Update 公共节点字段
|
||||
|
||||
V1 可写节点公共字段如下:
|
||||
|
||||
```ts
|
||||
interface OpenNodeWriteBase {
|
||||
id?: string;
|
||||
layer?: "background" | "normal" | "foreground";
|
||||
zIndex?: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface OpenChildNodeWriteBase extends OpenNodeWriteBase {
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface OpenSizedNodeWriteBase extends OpenChildNodeWriteBase {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
interface OpenShapeNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "shape";
|
||||
geometry: `dml:${string}`;
|
||||
text?: OpenTextWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenTextNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "text";
|
||||
text: OpenTextWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenConnectorNodeWrite extends OpenNodeWriteBase {
|
||||
type: "connector";
|
||||
start: OpenConnectorEndpointWrite;
|
||||
end: OpenConnectorEndpointWrite;
|
||||
routing: OpenConnectorRouting;
|
||||
waypoints?: OpenPoint[];
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenStickyNoteNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "stickyNote";
|
||||
text?: OpenTextWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenFrameNodeWrite extends OpenNodeWriteBase {
|
||||
type: "frame";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle?: 0;
|
||||
title?: {
|
||||
text: OpenTextWrite;
|
||||
box?: { width: number; height: number };
|
||||
};
|
||||
style?: OpenNodeStyleWrite;
|
||||
presentationOrder?: number;
|
||||
resizeMode?: "free" | "fixedAspectRatio";
|
||||
}
|
||||
|
||||
interface OpenGroupNodeWrite extends OpenChildNodeWriteBase {
|
||||
id: string;
|
||||
type: "group";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface OpenVectorNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "vector";
|
||||
resource: OpenManagedVectorResourceWrite;
|
||||
}
|
||||
|
||||
interface OpenIconNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "icon";
|
||||
catalogId: OpenIconCatalogId;
|
||||
}
|
||||
|
||||
interface OpenPathNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "path";
|
||||
path: OpenPathDataWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
type OpenNodeWrite =
|
||||
| OpenShapeNodeWrite
|
||||
| OpenTextNodeWrite
|
||||
| OpenConnectorNodeWrite
|
||||
| OpenStickyNoteNodeWrite
|
||||
| OpenFrameNodeWrite
|
||||
| OpenGroupNodeWrite
|
||||
| OpenVectorNodeWrite
|
||||
| OpenIconNodeWrite
|
||||
| OpenPathNodeWrite;
|
||||
```
|
||||
|
||||
上面的联合类型是 update 的字段白名单。各辅助结构和完整枚举值在第 7 节定义;
|
||||
未出现在对应分支中的字段不能发送。
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `id` | 可选的请求级临时 ID;非空、区分大小写、在请求内唯一。被引用时必须提供。 |
|
||||
| `type` | 必填,必须是 V1 可写类型。 |
|
||||
| `parentId` | 可选,引用同一请求中 group/frame 的临时 ID。 |
|
||||
| `x`、`y` | 除 connector 外必填;有父节点时为父节点相对坐标。 |
|
||||
| `width`、`height` | shape/text/stickyNote/frame/vector/icon/path 必填且大于 `0`;group/connector 只读。 |
|
||||
| `angle` | 可选,默认 `0`;frame 只允许 `0`;group/connector 只读。 |
|
||||
| `layer` | 可选;frame 默认 `background`,其他节点默认 `normal`。 |
|
||||
| `zIndex` | 可选的非负整数;相同值时按请求顺序稳定排序。 |
|
||||
| `hidden` | 可选布尔值,默认 `false`。 |
|
||||
|
||||
以下 query 字段禁止写回:
|
||||
|
||||
`children`、`absoluteBounds`、`locked`、`source`、`writeSupport`、
|
||||
`unsupportedFeatures`。
|
||||
|
||||
关系规则:
|
||||
|
||||
- `parentId` 只能引用同一请求中的 group 或 frame。
|
||||
- frame 和 connector 必须是页面直属节点,不能带 `parentId`。
|
||||
- group 可以嵌套,也可以放在 frame 中。
|
||||
- `children` 始终由各子节点的 `parentId` 推导。
|
||||
- 父子关系不能成环。
|
||||
- group 必须有临时 `id`、至少两个直接子节点,且不能全部隐藏。
|
||||
@@ -0,0 +1,395 @@
|
||||
# OpenNodes V1 — 支持矩阵、富文本和样式
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 7. 节点类型
|
||||
|
||||
### 7.1 支持矩阵
|
||||
|
||||
| `type` | query | update | 主要字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `shape` | 支持 | 支持 | `geometry`、`text?`、`style?` |
|
||||
| `text` | 支持 | 支持 | `text`、`style?` |
|
||||
| `connector` | 支持 | 支持 | `start`、`end`、`routing`、`waypoints?`、`style?` |
|
||||
| `stickyNote` | 支持 | 支持 | `text?`、`style?` |
|
||||
| `frame` | 支持 | 支持 | `title?`、`style?`、`presentationOrder?`、`resizeMode?` |
|
||||
| `group` | 支持 | 支持 | 子关系通过 `parentId` 表达 |
|
||||
| `image` | 支持 | 只读 | 仅公共字段 |
|
||||
| `vector` | 支持 | 支持 | `resource` |
|
||||
| `icon` | 支持 | 支持 | `catalogId` |
|
||||
| `path` | 支持 | 支持 | `path`、`style?` |
|
||||
| `pdf` | 支持 | 只读 | 仅公共字段 |
|
||||
| `media` | 支持 | 只读 | 仅公共字段 |
|
||||
| `webLink` | 支持 | 只读 | 仅公共字段 |
|
||||
| `table` | 支持 | 只读 | 仅公共字段 |
|
||||
| `chart` | 支持 | 只读 | 仅公共字段 |
|
||||
| `uml` | 支持 | 只读 | 仅公共字段 |
|
||||
| `swimlane` | 支持 | 只读 | 仅公共字段 |
|
||||
| `mind` | 支持 | 只读 | 仅公共字段 |
|
||||
| `timer` | 支持 | 只读 | 仅公共字段 |
|
||||
| `placeholder` | 支持 | 只读 | 仅公共字段 |
|
||||
| `unknown` | 支持 | 只读 | 未识别或尚未定义独立语义的节点统一映射到此类型 |
|
||||
|
||||
表中标记为只读的类型仍会完整返回公共几何、层级和顺序信息,但 update 提交会以
|
||||
`nodeTypeUnsupported` 失败。
|
||||
|
||||
`timer`、`table`、`webLink` 不支持 V1 update。query 仅返回这些节点的公共几何、
|
||||
层级、顺序和诊断字段,不承诺完整业务字段。文字 run 中的 `link` 是富文本能力,
|
||||
不属于 `webLink` 节点,V1 支持读写。
|
||||
|
||||
`webLink` 只保留只读查询;OpenNodes V1 不支持创建或重建该节点。
|
||||
|
||||
### 7.2 Text
|
||||
|
||||
query 和 update 均支持普通段落、无序列表、有序列表、多 block、多 run 和文字
|
||||
链接:
|
||||
|
||||
```ts
|
||||
interface OpenTextRun {
|
||||
text: string;
|
||||
marks?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strike?: boolean;
|
||||
color?: string;
|
||||
highlight?: string;
|
||||
};
|
||||
link?: { url: string };
|
||||
}
|
||||
|
||||
interface OpenTextBlock {
|
||||
type: "paragraph" | "bulletList" | "orderedList";
|
||||
horizontalAlign?: "left" | "center" | "right";
|
||||
runs: OpenTextRun[];
|
||||
}
|
||||
|
||||
interface OpenTextWrite {
|
||||
blocks: OpenTextBlock[];
|
||||
verticalAlign?: "top" | "center" | "bottom";
|
||||
padding?: number | [number, number];
|
||||
}
|
||||
|
||||
interface OpenText extends OpenTextWrite {
|
||||
plainText: string;
|
||||
writeSupport: "readWrite" | "readOnly";
|
||||
unsupportedFeatures?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `blocks.length >= 1`,每个 block 的 `type` 必须是 `paragraph`、
|
||||
`bulletList` 或 `orderedList`。
|
||||
- 每个 block 都必须满足 `runs.length >= 1`。
|
||||
- run 的 `text` 不能包含 `\r`、`\n`、U+2028 或 U+2029。换行和列表项使用
|
||||
独立 block 表达;每一段或每个列表项应写成一个 block,而不是把原始换行符
|
||||
放进单个 run。
|
||||
- 每个 `bulletList` / `orderedList` block 表示一个列表项;服务端会把连续且同类的
|
||||
block 解释为同一个列表中的多个列表项。
|
||||
- `link.url` 长度必须为 `1..2048`,不能包含控制字符、`<`、`>`。支持无 scheme
|
||||
的相对/裸链接,以及 `http`、`https`、`mailto`、`tel`、`dingtalk` scheme;
|
||||
`javascript:`、`data:` 等可执行或未知 scheme 会被拒绝。
|
||||
- 相邻且 URL 相同的 linked run 会呈现为同一个链接,同时保留各 run 自己的 marks。
|
||||
- `fontSize > 0`,padding 各项必须大于等于 `0`。
|
||||
- `plainText`、文本级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
|
||||
- text 节点必须提供 `text`;shape 和 stickyNote 的 `text` 可省略。
|
||||
- 受支持的 paragraph、列表、链接、多 run 都保持
|
||||
`writeSupport = "readWrite"`;未知 list style、非法链接、未支持的 block 或
|
||||
run marks 会令文本和所属节点变为 `readOnly`。
|
||||
|
||||
下面的文本会显示为两个段落,第一段由两个不同样式的 run 组成:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "left",
|
||||
"runs": [
|
||||
{
|
||||
"text": "OpenNodes ",
|
||||
"marks": { "fontSize": 18, "bold": true, "color": "#2563EB" }
|
||||
},
|
||||
{
|
||||
"text": "rich text",
|
||||
"marks": { "fontSize": 18, "italic": true, "color": "#0F172A" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "right",
|
||||
"runs": [
|
||||
{
|
||||
"text": "第二段",
|
||||
"marks": { "fontSize": 16, "underline": true, "color": "#047857" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center",
|
||||
"padding": [4, 8]
|
||||
}
|
||||
```
|
||||
|
||||
同一 `OpenTextWrite` 结构适用于独立 text 节点、shape 文本、stickyNote 文本和
|
||||
frame title。
|
||||
|
||||
下面三个 block 会生成两个无序列表项,其中第二项包含文字链接:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "bulletList",
|
||||
"runs": [{ "text": "准备输入数据" }]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"runs": [
|
||||
{
|
||||
"text": "查看钉钉文档",
|
||||
"marks": { "underline": true },
|
||||
"link": { "url": "https://alidocs.dingtalk.com" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "orderedList",
|
||||
"runs": [{ "text": "执行生成" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
颜色接受以下 CSS 形式:3/4/6/8 位十六进制、颜色名,以及
|
||||
`rgb()`、`rgba()`、`hsl()`、`hsla()`、`oklch()`、`lab()`、`lch()`、
|
||||
`color()`。字符串最长 128 字符,不能包含控制字符、`<`、`>` 或 `;`。
|
||||
这里只接受可独立解析的字面量;依赖外部样式上下文的 `var()`、`calc()`、
|
||||
`color-mix()` 等动态表达式不属于 V1。颜色名必须是标准 CSS named color,
|
||||
任意字母串不会被当成颜色。
|
||||
|
||||
### 7.3 Style
|
||||
|
||||
query 可表达:
|
||||
|
||||
- `none`、`solid`、`theme`、线性渐变、径向渐变和图片 paint。
|
||||
- shadow、blur 和 unknown effect。
|
||||
|
||||
V1 update 支持 `none`、`solid`、`theme`、线性渐变、九方向径向渐变,以及
|
||||
单个自定义 shadow。主题明暗参数和渐变色标位置使用 `0~100` 的百分比,
|
||||
不会归一化成 `0~1`:
|
||||
|
||||
```ts
|
||||
type OpenRadialGradientPosition =
|
||||
| "topLeft"
|
||||
| "topCenter"
|
||||
| "topRight"
|
||||
| "centerLeft"
|
||||
| "center"
|
||||
| "centerRight"
|
||||
| "bottomLeft"
|
||||
| "bottomCenter"
|
||||
| "bottomRight";
|
||||
|
||||
interface OpenColorStop {
|
||||
offset: number;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface OpenImagePaint {
|
||||
type: "image";
|
||||
resource: {
|
||||
kind: "managed" | "external" | "embedded" | "unresolved";
|
||||
resourceId?: string;
|
||||
};
|
||||
intrinsicWidth: number;
|
||||
intrinsicHeight: number;
|
||||
}
|
||||
|
||||
type OpenPaint =
|
||||
| { type: "none" }
|
||||
| { type: "solid"; color: string; opacity?: number }
|
||||
| {
|
||||
type: "theme";
|
||||
token: string;
|
||||
lumMod?: number;
|
||||
lumOff?: number;
|
||||
resolvedColor?: string;
|
||||
}
|
||||
| {
|
||||
type: "linearGradient";
|
||||
angle: number;
|
||||
stops: OpenColorStop[];
|
||||
}
|
||||
| {
|
||||
type: "radialGradient";
|
||||
position: OpenRadialGradientPosition | "custom";
|
||||
stops: OpenColorStop[];
|
||||
}
|
||||
| OpenImagePaint;
|
||||
|
||||
type OpenEffect =
|
||||
| {
|
||||
type: "shadow";
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
blur: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
| { type: "blur"; blur: number }
|
||||
| { type: "unknown" };
|
||||
|
||||
interface OpenNodeStyle {
|
||||
opacity?: number;
|
||||
fill?: OpenPaint;
|
||||
stroke?: {
|
||||
paint: OpenPaint;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
lineCap?: "butt" | "round" | "square";
|
||||
lineJoin?: "miter" | "round" | "bevel";
|
||||
};
|
||||
effects?: OpenEffect[];
|
||||
writeSupport: "readWrite" | "readOnly";
|
||||
unsupportedFeatures?: string[];
|
||||
}
|
||||
|
||||
interface OpenColorStopWrite {
|
||||
offset: number; // [0, 100],百分比
|
||||
color: string;
|
||||
opacity?: number; // [0, 1]
|
||||
}
|
||||
|
||||
type OpenPaintWrite =
|
||||
| { type: "none" }
|
||||
| { type: "solid"; color: string; opacity?: number }
|
||||
| {
|
||||
type: "theme";
|
||||
token: string;
|
||||
lumMod?: number; // [0, 100],默认 100
|
||||
lumOff?: number; // [0, 100],默认 0
|
||||
}
|
||||
| {
|
||||
type: "linearGradient";
|
||||
angle: number;
|
||||
stops: OpenColorStopWrite[];
|
||||
}
|
||||
| {
|
||||
type: "radialGradient";
|
||||
position: OpenRadialGradientPosition;
|
||||
stops: OpenColorStopWrite[];
|
||||
};
|
||||
|
||||
interface OpenShadowEffectWrite {
|
||||
type: "shadow";
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
blur: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface OpenNodeStyleWrite {
|
||||
opacity?: number;
|
||||
fill?: OpenPaintWrite;
|
||||
stroke?: {
|
||||
paint: OpenPaintWrite;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
lineCap?: "butt" | "round" | "square";
|
||||
lineJoin?: "miter" | "round" | "bevel";
|
||||
};
|
||||
effects?: OpenShadowEffectWrite[];
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- opacity 范围为 `[0, 1]`。
|
||||
- solid paint 的 `color` 与 `opacity` 在 query 后仍保持独立字段,不会合并为
|
||||
动态 CSS 表达式。
|
||||
- theme 的 `token` 必须能在当前白板主题中解析;
|
||||
`lumMod`、`lumOff` 范围均为 `[0, 100]`。token 不存在时在
|
||||
Request graph 阶段返回 `themeTokenNotFound`,不会写出部分节点。
|
||||
token 长度为 `1~64`,首尾不能有空白,也不能包含空白、控制字符、
|
||||
`<`、`>` 或 `;`。
|
||||
- query 的 theme paint 还会返回按当前白板主题计算出的 query-only
|
||||
`resolvedColor`。调用方写入时只传 `token/lumMod/lumOff`,不传
|
||||
`resolvedColor`。
|
||||
- 渐变必须至少有一个 stop;`offset` 范围为 `[0, 100]`,单位是百分比。
|
||||
- 线性渐变 `angle` 范围为 `[0, 360]`。
|
||||
- 径向渐变只接受上述九宫格位置。query 遇到九宫格以外的位置时返回
|
||||
`position: "custom"` 并将该节点标为只读。
|
||||
- `effects` 最多包含一个 `shadow`;`offsetX/offsetY` 必须是有限数,
|
||||
`blur >= 0`,shadow opacity 范围为 `[0, 1]`。
|
||||
- stroke width 和 dash 各项必须大于等于 `0`。
|
||||
- 一旦提供 stroke,`stroke.paint` 必填。
|
||||
- 样式级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
|
||||
- 能在当前白板主题中解析且明暗参数合法的 theme paint 可写。无法解析的主题
|
||||
token、越界的主题明暗参数、image paint、blur、unknown effect、叠加 effect
|
||||
或自定义径向位置会令节点只读;受支持的 theme、渐变和单个 shadow 本身不会
|
||||
令节点只读。
|
||||
|
||||
主题色示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"fill": {
|
||||
"type": "theme",
|
||||
"token": "ac3",
|
||||
"lumMod": 20,
|
||||
"lumOff": 80
|
||||
},
|
||||
"stroke": {
|
||||
"paint": {
|
||||
"type": "theme",
|
||||
"token": "sk1",
|
||||
"lumMod": 80,
|
||||
"lumOff": 20
|
||||
},
|
||||
"width": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
示例中的 `ac3`、`sk1` 只是主题 token 示例,不是所有白板都可用的全局枚举。
|
||||
调用方只能使用已确认可由当前白板主题解析的 token;无法确认时应改用
|
||||
`solid` 颜色。
|
||||
|
||||
DWS 不提供新增、修改或切换主题的命令。当前白板没有有效主题时,应改用
|
||||
`solid`。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"fill": {
|
||||
"type": "linearGradient",
|
||||
"angle": 35,
|
||||
"stops": [
|
||||
{ "offset": 0, "color": "#1677ff", "opacity": 0.4 },
|
||||
{ "offset": 100, "color": "#69b1ff" }
|
||||
]
|
||||
},
|
||||
"effects": [
|
||||
{
|
||||
"type": "shadow",
|
||||
"offsetX": 8,
|
||||
"offsetY": 8,
|
||||
"blur": 19,
|
||||
"color": "rgba(93,190,172,1)",
|
||||
"opacity": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
未传 `style` 时使用节点类型的默认样式。调用方如需稳定的视觉结果,应显式传入
|
||||
`fill` 和 `stroke`。
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# OpenNodes V1 — Shape、Text、Sticky note、Frame、Group 和 Connector
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
### 7.4 Shape
|
||||
|
||||
shape 必须提供 `geometry`,格式为 `dml:<name>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "shape-1",
|
||||
"type": "shape",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 160,
|
||||
"height": 100,
|
||||
"geometry": "dml:roundRect",
|
||||
"style": {
|
||||
"fill": {
|
||||
"type": "solid",
|
||||
"color": "#DCEEFF"
|
||||
},
|
||||
"stroke": {
|
||||
"paint": {
|
||||
"type": "solid",
|
||||
"color": "#225588"
|
||||
},
|
||||
"width": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
query 可能返回 `adjustments`,但 V1 update 不支持写入;带 adjustments 的
|
||||
shape 会标为只读。`dml-v1` 的完整 geometry 目录见附录 A。
|
||||
|
||||
### 7.5 Text node 与 Sticky note
|
||||
|
||||
text node 使用公共几何、必填 `text` 和可选 `style`。
|
||||
|
||||
stickyNote 使用公共几何、可选 `text` 和可选 `style`。省略 `text` 时创建空
|
||||
便签。query 还可能返回:
|
||||
|
||||
- `creator`:创建者展示信息。
|
||||
- `tags`:标签 ID、文本和背景 paint。
|
||||
|
||||
这两个字段是 query-only;存在 creator 或 tags 的 stickyNote 会被标为只读。
|
||||
|
||||
### 7.6 Frame
|
||||
|
||||
frame 的 update 结构见第 6 节 `OpenFrameNodeWrite`。
|
||||
|
||||
约束:
|
||||
|
||||
- frame 必须是页面直属节点,不能带 `parentId`。
|
||||
- angle 只允许 `0`。
|
||||
- `presentationOrder` 是非负整数,并且不能和已有或本次创建的 frame 冲突。
|
||||
- frame 的子节点通过子节点 `parentId` 引用 frame 临时 ID。
|
||||
- frame 不能包含 frame 或 connector。
|
||||
- frame 默认 layer 为 `background`。
|
||||
|
||||
### 7.7 Group
|
||||
|
||||
group 的写入字段只有公共字段中的 `id`、`parentId?`、`x`、`y`、`layer?`、
|
||||
`zIndex?` 和 `hidden?`。其 width、height、angle 和 children 都由服务端根据
|
||||
子节点推导。
|
||||
|
||||
group 必须:
|
||||
|
||||
- 提供临时 `id`。
|
||||
- 至少包含两个直接子节点。
|
||||
- 至少有一个直接子节点可见。
|
||||
|
||||
group 的任一子节点为只读时,query 会把 group 一并标为只读。
|
||||
|
||||
### 7.8 Connector
|
||||
|
||||
connector 的几何由端点和路由推导,因此 update 不能提供 `x`、`y`、
|
||||
`width`、`height` 或 `angle`。
|
||||
|
||||
query 的连接线结构如下:
|
||||
|
||||
```ts
|
||||
type OpenConnectorRouting =
|
||||
| "straight"
|
||||
| "polyline"
|
||||
| "curve"
|
||||
| "orthogonal";
|
||||
|
||||
interface OpenConnectorMarker {
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
type OpenConnectorAnchor =
|
||||
| {
|
||||
mode: "fixed";
|
||||
side: "top" | "right" | "bottom" | "left";
|
||||
position: OpenPoint;
|
||||
}
|
||||
| {
|
||||
mode: "fixed";
|
||||
side: "custom";
|
||||
position: OpenPoint;
|
||||
};
|
||||
|
||||
type OpenConnectorEndpoint =
|
||||
| {
|
||||
type: "point";
|
||||
point: OpenPoint;
|
||||
marker: OpenConnectorMarker;
|
||||
}
|
||||
| {
|
||||
type: "node";
|
||||
nodeRef: { scope: "document"; id: string };
|
||||
anchor: OpenConnectorAnchor;
|
||||
resolvedPoint: OpenPoint;
|
||||
marker: OpenConnectorMarker;
|
||||
};
|
||||
|
||||
interface OpenBezierSegment {
|
||||
start: OpenPoint;
|
||||
control1: OpenPoint;
|
||||
control2: OpenPoint;
|
||||
end: OpenPoint;
|
||||
}
|
||||
|
||||
type OpenResolvedConnectorPath =
|
||||
| { type: "polyline"; points: OpenPoint[] }
|
||||
| { type: "bezier"; segments: OpenBezierSegment[] };
|
||||
```
|
||||
|
||||
update 端点有两种形式:
|
||||
|
||||
```ts
|
||||
type OpenConnectorEndpointWrite =
|
||||
| {
|
||||
type: "point";
|
||||
point: { x: number; y: number };
|
||||
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
|
||||
}
|
||||
| {
|
||||
type: "node";
|
||||
nodeRef: { scope: "request"; id: string };
|
||||
anchor?:
|
||||
| { mode: "auto" }
|
||||
| {
|
||||
mode: "fixed";
|
||||
side: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
|
||||
};
|
||||
```
|
||||
|
||||
query 的 marker `catalogId` 使用 `string`,以便返回无法映射的既有 marker;
|
||||
update 只接受上面列出的 `"none"`、`"arrow.open"`、`"arrow.filled"`。
|
||||
|
||||
路由规则:
|
||||
|
||||
| `routing` | `waypoints` |
|
||||
| --- | --- |
|
||||
| `straight` | 禁止提供,包括空数组。 |
|
||||
| `polyline` | 必须至少提供一个。 |
|
||||
| `curve` | 可选。 |
|
||||
| `orthogonal` | 可选;显式点路径的相邻线段必须水平或垂直。 |
|
||||
|
||||
其他约束:
|
||||
|
||||
- 所有 point 和 waypoint 都使用页面绝对坐标。
|
||||
- node 端点只能引用同一请求中的 shape、text、stickyNote、frame、group 或 path。
|
||||
- node 端点不能引用隐藏节点、connector 或 query 中既有节点。
|
||||
- update 引用范围固定为 `scope: "request"`;query 返回的节点引用范围为
|
||||
`scope: "document"`,不能直接回写。
|
||||
- 同一连接线的两端不能引用同一个节点。
|
||||
- 零长度或无效路径会被拒绝。
|
||||
- marker 省略时默认为 `none`。
|
||||
- anchor 省略时按 `auto` 处理。
|
||||
|
||||
query 额外返回服务端解析后的:
|
||||
|
||||
- node 端点 `resolvedPoint`。
|
||||
- fixed anchor 的归一化 `position`。
|
||||
- `resolvedPath`,类型为 polyline points 或 cubic bezier segments。
|
||||
- 由真实路径推导的 `absoluteBounds`。
|
||||
|
||||
这些解析字段都是 query-only。
|
||||
@@ -0,0 +1,313 @@
|
||||
# OpenNodes V1 — Vector、Icon 和 Path
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
### 7.9 Vector(已上传 SVG/矢量资源)
|
||||
|
||||
`vector` 表示已经通过 `dws doc media upload` 获得稳定引用的 SVG/矢量图片。
|
||||
OpenNodes 只接收上传结果中的资源引用,不接收本地路径或原始 SVG/XML 内容。
|
||||
|
||||
上传时必须使用与后续白板更新相同的文档 `nodeId`:
|
||||
|
||||
```bash
|
||||
dws doc media upload \
|
||||
--node <DOC_NODE_ID> \
|
||||
--file ./icon.svg \
|
||||
--mime-type image/svg+xml \
|
||||
--format json
|
||||
```
|
||||
|
||||
将上传结果的 `resourceId` 和 `resourceUrl` 分别写入
|
||||
`resource.resourceId` 和 `resource.url`。
|
||||
|
||||
Update 必须提供完整的托管资源信息:
|
||||
|
||||
```ts
|
||||
interface OpenManagedVectorResourceWrite {
|
||||
kind: "managed";
|
||||
resourceId: string;
|
||||
url: string;
|
||||
}
|
||||
```
|
||||
|
||||
完整节点结构见第 6 节 `OpenVectorNodeWrite`。
|
||||
|
||||
`resource` 字段规则:
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| `kind` | 是 | 当前只允许固定值 `managed`,表示资源已经通过 DWS 上传。 |
|
||||
| `resourceId` | 是 | 资源稳定 ID,长度 1~256,只允许字母、数字、`.`、`_`、`:`、`-`。 |
|
||||
| `url` | 是 | 已上传资源地址,最长 4096;必须包含且只能包含一个同值的 `resourceId` 查询参数。 |
|
||||
|
||||
`url` 必须直接使用 `dws doc media upload` 返回的 `resourceUrl`,不得自行拼装
|
||||
或修改。
|
||||
|
||||
以下内容会被拒绝:
|
||||
|
||||
- 原始 SVG/XML、`data:`、`blob:`、`http:` URL。
|
||||
- `//host/path` 协议相对地址,以及自行构造或修改的其他相对地址。
|
||||
- 含空白、控制字符、反斜杠、fragment 或用户凭证的 URL。
|
||||
- URL 缺少 `resourceId`、重复出现 `resourceId`,或者 URL 中 ID 与显式
|
||||
`resource.resourceId` 不一致。
|
||||
- `resource` 缺失、字段不完整、`kind` 不是 `managed`,或包含未知字段。
|
||||
|
||||
完整 Append 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "uploaded-svg-1",
|
||||
"type": "vector",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 180,
|
||||
"angle": 0,
|
||||
"resource": {
|
||||
"kind": "managed",
|
||||
"resourceId": "0c1c94e1-f9af-4228-b32f-42bbd1555253",
|
||||
"url": "https://resources.example.com/assets/opaque-path?resourceId=0c1c94e1-f9af-4228-b32f-42bbd1555253"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"overwrite": false
|
||||
}
|
||||
```
|
||||
|
||||
示例中的 `resources.example.com` 是占位域名,实际调用必须使用
|
||||
`dws doc media upload` 返回的 `resourceUrl`。
|
||||
|
||||
`resourceId` 同时显式出现并包含在 URL 中,用于校验资源身份与地址是否一致。
|
||||
两者必须来自同一次 `dws doc media upload` 结果。
|
||||
|
||||
Query 的 `resource` 可能是:
|
||||
|
||||
```ts
|
||||
type OpenVectorResource =
|
||||
| { kind: "managed"; resourceId: string; url: string }
|
||||
| { kind: "external" | "embedded" | "unresolved" };
|
||||
```
|
||||
|
||||
- 能识别为托管资源的引用返回完整 `managed` 信息。
|
||||
- HTTP(S) 外链但不满足托管资源契约时返回 `external`。
|
||||
- `data:`/`blob:` 返回 `embedded`。
|
||||
- 其他缺失或无法识别的地址返回 `unresolved`。
|
||||
- 非 `managed` 资源会令节点只读,并分别产生
|
||||
`vector.resource.external`、`vector.resource.embedded` 或
|
||||
`vector.resource.unresolved`。
|
||||
|
||||
V1 vector update 不接受 `style`。既有节点包含 V1 无法表达的 fill、stroke、
|
||||
opacity、effect 或 adjustments 时,query 仍可读取资源和几何,但节点会标为只读。
|
||||
不影响资源内容的兼容性装饰不会单独令节点变为只读。
|
||||
|
||||
DWS 会校验资源引用。上传与 `whiteboard update` 必须使用同一个文档
|
||||
`nodeId`;不要跨文档复用资源,也不要使用临时 `uploadUrl`。
|
||||
|
||||
### 7.10 Icon(内置图标)
|
||||
|
||||
`icon` 表示内置图标。OpenNodes 使用版本化的 `catalogId` 作为稳定标识,
|
||||
允许值见下列类型定义和附录 B。
|
||||
|
||||
Update 结构:
|
||||
|
||||
```ts
|
||||
type OpenIconCatalogId =
|
||||
| `emoji/${
|
||||
| "happy"
|
||||
| "smile"
|
||||
| "laugh"
|
||||
| "fighting"
|
||||
| "like"
|
||||
| "ok"
|
||||
| "please"
|
||||
| "face-plam"
|
||||
| "tears-of-joy"
|
||||
| "cry"
|
||||
| "question"
|
||||
| "face-with-sweat"
|
||||
| "bloody-nose"
|
||||
| "doggy"}`
|
||||
| `tools/${
|
||||
| "pad"
|
||||
| "blue-note"
|
||||
| "yellow-notes"
|
||||
| "chart"
|
||||
| "chart-2"
|
||||
| "pencil"
|
||||
| "pen"
|
||||
| "bag"
|
||||
| "rocket"
|
||||
| "fire"
|
||||
| "gold"
|
||||
| "light"
|
||||
| "pin"
|
||||
| "red-flag"
|
||||
| "tea"
|
||||
| "island"
|
||||
| "ball"
|
||||
| "lucky-fish"
|
||||
| "coffee"
|
||||
| "milky-tea"
|
||||
| "pan"}`
|
||||
| `priority/priority-${1 | 2 | 3 | 4 | 5 | 6 | 7}`
|
||||
| `task/${
|
||||
| "task-start"
|
||||
| "task-oct"
|
||||
| "task-3oct"
|
||||
| "task-half"
|
||||
| "task-5oct"
|
||||
| "task-7oct"
|
||||
| "task-done"}`;
|
||||
```
|
||||
|
||||
完整节点结构见第 6 节 `OpenIconNodeWrite`。
|
||||
|
||||
完整 Append 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "pencil-icon",
|
||||
"type": "icon",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"catalogId": "tools/pencil"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overwrite": false
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `catalogId` 必填,格式为 `<group>/<name>`,并且必须精确命中附录 B 的
|
||||
`dml-v1` allowlist;大小写、连字符和历史拼写都不能自行修正。
|
||||
- 当前目录有 `emoji`、`tools`、`priority`、`task` 四组,共 49 项。
|
||||
- update 只接受 `catalogId`,不接受 `group`、`name`、`style` 或 `resource`。
|
||||
- 内置 icon 不需要调用方上传资源,也不需要 `resourceId`。
|
||||
- 任意已上传 SVG 或自定义图标应使用 `vector`,并按 7.9 节提供完整
|
||||
`resource` 信息,不能伪造一个 icon `catalogId`。
|
||||
- icon 可以作为 group/frame 子节点;V1 connector 的 node 端点当前仍不接受
|
||||
icon 作为目标。
|
||||
|
||||
Query 返回相同的 `catalogId`。既有 icon 无法映射到当前目录时,节点仍会以
|
||||
`type: "icon"` 返回以便诊断,但 `writeSupport` 为 `readOnly`,原因包含
|
||||
`icon.catalogId`。非默认 opacity、filter/effect、text 或 adjustments 同样会令节点
|
||||
只读。
|
||||
|
||||
### 7.11 Path(自由画笔)
|
||||
|
||||
`path` 表示自由画笔轨迹。OpenNodes 保留两组互相独立的尺寸:
|
||||
|
||||
- 节点公共 `width` / `height` 是画布上的实际渲染尺寸,缩放节点时会变化。
|
||||
- `path.intrinsicWidth` / `path.intrinsicHeight` 是 SVG path 自身的坐标空间尺寸,
|
||||
表示 `path.data` 使用的内部坐标空间。
|
||||
|
||||
```ts
|
||||
interface OpenPathData {
|
||||
data: string;
|
||||
intrinsicWidth: number;
|
||||
intrinsicHeight: number;
|
||||
}
|
||||
|
||||
type OpenPathDataWrite = OpenPathData;
|
||||
```
|
||||
|
||||
完整 update 节点结构见第 6 节 `OpenPathNodeWrite`。query 和 update 的 `path`
|
||||
字段结构相同,但 update 仍必须满足下方命令子集和大小限制。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "freehand-stroke",
|
||||
"type": "path",
|
||||
"x": 120,
|
||||
"y": 100,
|
||||
"width": 500,
|
||||
"height": 150,
|
||||
"path": {
|
||||
"data": "M0,75 Q50,0 100,75 Q150,150 200,75 Q250,0 300,75 Q350,150 400,75 Q450,0 500,75",
|
||||
"intrinsicWidth": 500,
|
||||
"intrinsicHeight": 150
|
||||
},
|
||||
"style": {
|
||||
"fill": { "type": "none" },
|
||||
"stroke": {
|
||||
"paint": { "type": "solid", "color": "#7C3AED" },
|
||||
"width": 10,
|
||||
"lineCap": "round",
|
||||
"lineJoin": "round"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
下面是一个仍然只使用 V1 命令子集、但包含 12 段二次贝塞尔曲线的蝴蝶轮廓。
|
||||
它显式回到起点,因此不需要使用尚未支持的 `Z`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "complex-butterfly-path",
|
||||
"type": "path",
|
||||
"x": 1500,
|
||||
"y": 1215,
|
||||
"width": 500,
|
||||
"height": 420,
|
||||
"path": {
|
||||
"data": "M250,180 Q210,105 145,70 Q55,25 35,110 Q10,195 125,220 Q35,275 80,355 Q125,415 210,315 Q235,285 250,250 Q265,285 290,315 Q375,415 420,355 Q465,275 375,220 Q490,195 465,110 Q445,25 355,70 Q290,105 250,180",
|
||||
"intrinsicWidth": 500,
|
||||
"intrinsicHeight": 420
|
||||
},
|
||||
"style": {
|
||||
"opacity": 0.96,
|
||||
"fill": {
|
||||
"type": "solid",
|
||||
"color": "#EDE9FE",
|
||||
"opacity": 0.72
|
||||
},
|
||||
"stroke": {
|
||||
"paint": {
|
||||
"type": "solid",
|
||||
"color": "#6D28D9"
|
||||
},
|
||||
"width": 8,
|
||||
"lineCap": "round",
|
||||
"lineJoin": "round"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V1 path 写入约束如下:
|
||||
|
||||
- `data` 必须是一个绝对 `M`,后跟至少一个显式写出的绝对 `Q`;不接受相对命令,
|
||||
也不接受 `L`、`C`、`A`、`Z` 等通用 SVG 命令。
|
||||
- 所有命令参数必须完整且为有限数值;单节点 `data` 最长 1 MiB,最多 50,000 个
|
||||
命令。
|
||||
- `intrinsicWidth` 和 `intrinsicHeight` 必须为有限正数;它们不要求等于节点的
|
||||
`width` 和 `height`。
|
||||
- 未传 `style` 时,默认使用透明填充、`#222222` 描边、宽度 5,以及 round
|
||||
line cap/join。需要稳定视觉结果时仍应显式传入 `style`。
|
||||
- path 可以作为 group/frame 的子节点,也可以作为同一 update 请求中 connector
|
||||
的 node 端点。
|
||||
|
||||
query 会把其他能够解析的 SVG path 保留为 `type: "path"`,但标记为
|
||||
`readOnly`,原因包含 `path.commands`;超出上述 V1 限制时使用
|
||||
`path.data.size` 或 `path.commands.limit`。既有 path 带 text、adjustments、非
|
||||
`nonzero` fillRule 或 V1 无法表达的样式时也会只读。仅包含不影响画笔几何的
|
||||
兼容性信息时,不会因此变为只读。theme stroke 遵循通用 style 契约:query 会
|
||||
保留 token 和明暗参数;只要 token 能在当前白板主题中解析,就可以按 theme
|
||||
paint 回写。
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
# OpenNodes V1 — Update 示例、回写规则、错误模型和 writeSupport
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 8. Update 示例
|
||||
|
||||
### 8.1 Append 一个文本节点
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "left",
|
||||
"runs": [
|
||||
{
|
||||
"text": "Hello OpenNodes",
|
||||
"marks": {
|
||||
"fontSize": 16,
|
||||
"color": "#223344"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center",
|
||||
"padding": [2, 4]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Append 两个形状和一条引用连接线
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "left",
|
||||
"type": "shape",
|
||||
"x": 80,
|
||||
"y": 100,
|
||||
"width": 120,
|
||||
"height": 80,
|
||||
"geometry": "dml:roundRect"
|
||||
},
|
||||
{
|
||||
"id": "right",
|
||||
"type": "shape",
|
||||
"x": 360,
|
||||
"y": 100,
|
||||
"width": 120,
|
||||
"height": 80,
|
||||
"geometry": "dml:roundRect"
|
||||
},
|
||||
{
|
||||
"id": "line",
|
||||
"type": "connector",
|
||||
"start": {
|
||||
"type": "node",
|
||||
"nodeRef": {
|
||||
"scope": "request",
|
||||
"id": "left"
|
||||
},
|
||||
"anchor": {
|
||||
"mode": "fixed",
|
||||
"side": "right"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "node",
|
||||
"nodeRef": {
|
||||
"scope": "request",
|
||||
"id": "right"
|
||||
},
|
||||
"anchor": {
|
||||
"mode": "fixed",
|
||||
"side": "left"
|
||||
},
|
||||
"marker": {
|
||||
"catalogId": "arrow.filled"
|
||||
}
|
||||
},
|
||||
"routing": "straight"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Overwrite 整页
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": true,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "replacement",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"runs": [
|
||||
{
|
||||
"text": "Replacement content"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 清空当前页面
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": true,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Query 数据不能直接回写
|
||||
|
||||
query 是完整可读投影,update 是受约束的创建协议,两者不是对称 JSON:
|
||||
|
||||
| Query 字段/能力 | Update 处理方式 |
|
||||
| --- | --- |
|
||||
| 真实 `id` | 只能作为请求级临时 ID;不能引用既有 document 节点。 |
|
||||
| `children` | 删除,通过子节点 `parentId` 重建。 |
|
||||
| `absoluteBounds` | 删除;普通节点使用 `x/y/width/height`,connector 使用端点和路由字段。 |
|
||||
| `locked`、`source`、`writeSupport`、`unsupportedFeatures` | 删除,均为 query-only。 |
|
||||
| 文本 `plainText` | 删除,由服务端根据 paragraph 和 run 重新计算。 |
|
||||
| 多 paragraph、列表、多 run、文字链接 | 可以保留;每个 block 必须是受支持类型,且 run 内不能包含原始换行符。 |
|
||||
| 未知 list style、非法链接或未支持的 block/marks | 需要移除或降级为受支持的 block/run。 |
|
||||
| theme paint | 保留 `token/lumMod/lumOff`,删除 query-only `resolvedColor`;token 必须能在当前白板主题中解析。 |
|
||||
| image paint | 需要降级成受支持的 paint,或不更新该节点。 |
|
||||
| 受支持的 linear/radial gradient、单个 shadow | 可以保留;gradient offset 使用 `0~100`,radial `custom` 不能回写。 |
|
||||
| blur、unknown 或叠加 effects | 需要删除、降级成单个 shadow,或不更新该节点。 |
|
||||
| connector `scope: "document"` | 不能回写;改为引用同一请求节点的 `scope: "request"`。 |
|
||||
| connector `resolvedPoint`、`position`、`resolvedPath` | 删除,均由服务端重新计算。 |
|
||||
| shape `adjustments` | V1 不支持写入。 |
|
||||
| stickyNote `creator`、`tags` | V1 不支持写入。 |
|
||||
|
||||
即使 query 节点显示 `writeSupport = "readWrite"`,update 仍会对版本、目录、
|
||||
字段和请求关系做完整校验。调用方不应跳过 update 错误处理。
|
||||
|
||||
## 10. 错误模型
|
||||
|
||||
### 10.1 顶层错误码
|
||||
|
||||
| 错误码 | 含义 |
|
||||
| --- | --- |
|
||||
| `invalidRequest.whiteboard.schemaInvalid` | JSON、字段或节点 schema 不合法。 |
|
||||
| `invalidRequest.whiteboard.catalogVersionUnsupported` | catalogVersion 不受支持。 |
|
||||
| `invalidRequest.whiteboard.validationFailed` | 节点间引用、父子关系或路径关系不合法。 |
|
||||
| `invalidRequest.whiteboard.emptySource` | append 的 nodes 为空。 |
|
||||
| `invalidRequest.whiteboard.overwriteUnsafe` | overwrite 安全预检失败。 |
|
||||
|
||||
### 10.2 DWS 错误输出
|
||||
|
||||
远端校验失败时,DWS 以统一 CLI 错误结构返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"category": "api",
|
||||
"reason": "business_error",
|
||||
"server_key": "whiteboard",
|
||||
"server_error_code": "invalidRequest.whiteboard.validationFailed",
|
||||
"message": "Whiteboard request graph is invalid",
|
||||
"trace_id": "TRACE_ID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
部分服务错误会使用更宽泛的 `invalidRequest.inputArgs.invalid`。Agent 应结合
|
||||
`server_error_code` 和 `message` 修正输入;需要排障时保留 `trace_id`。JSON 或
|
||||
信封级错误可能由 CLI 本地返回,不一定包含 `server_key` 和 `trace_id`。
|
||||
|
||||
校验类错误不可通过原样重试恢复。常见原因包括:
|
||||
|
||||
- Schema:缺少字段、未知字段、类型或枚举值错误、提交 query-only 字段、
|
||||
节点类型不支持。
|
||||
- 请求关系:临时 ID 重复、引用不存在、父子关系非法、连接线目标不支持、
|
||||
路径退化或主题 token 不存在。
|
||||
- Overwrite 预检:锁定节点、禁止删除、关联数据无法安全处理或删除失败。
|
||||
|
||||
任一阶段失败都不会保留部分更新。
|
||||
|
||||
## 11. writeSupport 的含义
|
||||
|
||||
`writeSupport` 表示当前 query 节点是否能由 V1 update 无损表达,不代表用户
|
||||
权限,也不代表 overwrite 是否允许移除该既有节点。
|
||||
|
||||
以下 `unsupportedFeatures` 均为对外返回的诊断枚举值:
|
||||
|
||||
- `node.source.master`、`node.locked`、`node.role`、`node.placeholder`、
|
||||
`node.extras`、`node.ability`。
|
||||
- `node.type.image`、`node.type.pdf`、`node.type.media`、
|
||||
`node.type.webLink`、`node.type.table`、`node.type.chart`、
|
||||
`node.type.uml`、`node.type.swimlane`、`node.type.mind`、
|
||||
`node.type.timer`、`node.type.placeholder`、`node.type.unknown`。
|
||||
- `text.list.unsupported`、`text.link.unsupported`、`text.block.unsupported`、
|
||||
`text.lineBreak.unsupported`、`text.marks.unsupported`、
|
||||
`text.color.unsupported`、`text.highlight.unsupported`。
|
||||
- `style.fill.color`、`style.fill.opacity`、`style.fill.theme.token`、
|
||||
`style.fill.theme.unresolved`、`style.fill.theme.modifier`、
|
||||
`style.fill.theme.opacity`、
|
||||
`style.fill.gradient.angle`、`style.fill.gradient.offset`、
|
||||
`style.fill.gradient.color`、`style.fill.gradient.opacity`、
|
||||
`style.fill.gradient.position`、`style.fill.image`。
|
||||
- `style.stroke.color`、`style.stroke.opacity`、`style.stroke.theme.token`、
|
||||
`style.stroke.theme.unresolved`、
|
||||
`style.stroke.theme.modifier`、`style.stroke.theme.opacity`、
|
||||
`style.stroke.gradient.angle`、`style.stroke.gradient.offset`、
|
||||
`style.stroke.gradient.color`、`style.stroke.gradient.opacity`、
|
||||
`style.stroke.gradient.position`、`style.stroke.image`。
|
||||
- `style.effects`。
|
||||
- `shape.adjustments`、`stickyNote.creator`、`stickyNote.tags`。
|
||||
- `vector.resource.external`、`vector.resource.embedded`、
|
||||
`vector.resource.unresolved`、`vector.fill`、`vector.stroke`、
|
||||
`vector.opacity`、`vector.effect`、`vector.adjustments`。
|
||||
- `icon.catalogId`、`icon.opacity`、`icon.effect`、`icon.text`、
|
||||
`icon.adjustments`。
|
||||
- `path.commands`、`path.data.size`、`path.commands.limit`、`path.text`、
|
||||
`path.fillRule`、`path.adjustments`。
|
||||
- `connector.parent`、`connector.marker.unsupported`、
|
||||
`connector.target.unexposed`、`connector.target.unsupported`、
|
||||
`connector.anchor.unresolved`、`connector.anchor.custom`、
|
||||
`connector.selfLoop`。
|
||||
- `group.angle`、`group.children.minimum`、`group.children.hidden`、
|
||||
`group.child.readOnly`。
|
||||
- `frame.angle`、`frame.child.frame`、`frame.child.readOnly`。
|
||||
|
||||
调用方应把 `unsupportedFeatures` 当作诊断信息,不应把当前枚举穷举写死为
|
||||
业务逻辑。真正可写与否以 `writeSupport` 和 update 校验结果为准。
|
||||
@@ -0,0 +1,61 @@
|
||||
# OpenNodes V1 — dml-v1 Geometry 和 Icon 完整目录
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 附录 A:dml-v1 geometry 目录
|
||||
|
||||
写入时在以下名称前加 `dml:`,例如 `rect` 写成 `dml:rect`。当前目录共
|
||||
183 项,目录版本由 `catalogVersion = "dml-v1"` 标识。
|
||||
|
||||
```text
|
||||
accentBorderCallout1 accentBorderCallout2 accentBorderCallout3
|
||||
accentCallout1 accentCallout2 accentCallout3 actionButtonBackPrevious
|
||||
actionButtonBeginning actionButtonBlank actionButtonDocument actionButtonEnd
|
||||
actionButtonForwardNext actionButtonHelp actionButtonHome
|
||||
actionButtonInformation actionButtonMovie actionButtonReturn actionButtonSound
|
||||
active allGeneralization arc attribute bentArrow bentUpArrow bevel bind blockArc
|
||||
borderCallout1 borderCallout2 borderCallout3 bracePair bracketPair callout1
|
||||
callout2 callout3 can chevron chord circularArrow cloud cloudCallout comment
|
||||
component control convert corner cube curvedDownArrow curvedLeftArrow
|
||||
curvedRightArrow curvedUpArrow dataStorage decagon delete diagStripe diamond
|
||||
dodecagon donut doubleWave downArrow downArrowCallout ellipse ellipseRibbon
|
||||
ellipseRibbon2 entity entitySet flowChartAlternateProcess flowChartCollate
|
||||
flowChartConnector flowChartDecision flowChartDelay flowChartDisplay
|
||||
flowChartDocument flowChartExtract flowChartInputOutput flowChartInternalStorage
|
||||
flowChartMagneticDisk flowChartMagneticDrum flowChartMagneticTape
|
||||
flowChartManualInput flowChartManualOperation flowChartMerge
|
||||
flowChartMultidocument flowChartOffpageConnector flowChartOnlineStorage
|
||||
flowChartOr flowChartPredefinedProcess flowChartPreparation
|
||||
flowChartPunchedCard flowChartPunchedTape flowChartSort
|
||||
flowChartSummingJunction flowChartTerminator foldedCorner frame generalization
|
||||
halfFrame heart heptagon hexagon history homePlate horizontalDivCircle
|
||||
horizontalScroll irregularSeal1 irregularSeal2 leftArrow leftArrowCallout
|
||||
leftBrace leftBracket leftRightArrow leftRightArrowCallout leftRightUpArrow
|
||||
leftUpArrow lightningBolt mathDivide mathEqual mathMinus mathMultiply
|
||||
mathNotEqual mathPlus moon multiClass multiValuedAttribute noSmoking node
|
||||
nonIsoscelesTrapezoid notchedRightArrow octagon parallelogram pentagon person
|
||||
pie plaque plus quadArrow quadArrowCallout receiveSignal rect relationship
|
||||
ribbon ribbon2 rightArrow rightArrowCallout rightBrace rightBracket round1Rect
|
||||
round2DiagRect round2SameRect roundRect rtTriangle smileyFace snip1Rect
|
||||
snip2DiagRect snip2SameRect snipRoundRect star10 star12 star16 star24 star32
|
||||
star4 star5 star6 star7 star8 stripedRightArrow sun teardrop triangle upArrow
|
||||
upArrowCallout upDownArrow user uturnArrow verticalDivCircle verticalScroll
|
||||
wave weakEntitySet weakRelationship wedgeEllipseCallout wedgeRectCallout
|
||||
wedgeRoundRectCallout
|
||||
```
|
||||
|
||||
## 附录 B:dml-v1 icon 目录
|
||||
|
||||
写入时必须使用完整的 `<group>/<name>`。当前共 4 组 49 项,目录版本由
|
||||
`catalogVersion = "dml-v1"` 标识。
|
||||
|
||||
| group | 数量 | name(组成 `group/name`) |
|
||||
| --- | ---: | --- |
|
||||
| `emoji` | 14 | `happy`、`smile`、`laugh`、`fighting`、`like`、`ok`、`please`、`face-plam`、`tears-of-joy`、`cry`、`question`、`face-with-sweat`、`bloody-nose`、`doggy` |
|
||||
| `tools` | 21 | `pad`、`blue-note`、`yellow-notes`、`chart`、`chart-2`、`pencil`、`pen`、`bag`、`rocket`、`fire`、`gold`、`light`、`pin`、`red-flag`、`tea`、`island`、`ball`、`lucky-fish`、`coffee`、`milky-tea`、`pan` |
|
||||
| `priority` | 7 | `priority-1`、`priority-2`、`priority-3`、`priority-4`、`priority-5`、`priority-6`、`priority-7` |
|
||||
| `task` | 7 | `task-start`、`task-oct`、`task-3oct`、`task-half`、`task-5oct`、`task-7oct`、`task-done` |
|
||||
|
||||
注意:`emoji/face-plam` 是 V1 保留的历史兼容枚举值,拼写虽然异常但属于协议值;
|
||||
传入 `emoji/face-palm` 会被拒绝。
|
||||
@@ -0,0 +1,308 @@
|
||||
# 钉钉白板常用 Recipes
|
||||
|
||||
以下各 Recipe 的 JSON 都写入本地文件,再通过 `dws whiteboard update --source <FILE.json>`
|
||||
传给白板。执行任何远端写入前,必须先向用户展示影响并取得明确确认;确认后
|
||||
才可添加 `--yes`:
|
||||
|
||||
```bash
|
||||
dws whiteboard update \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--source <FILE.json> \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
每次更新后都要再次执行 `dws whiteboard query ... --format json` 回读验证。
|
||||
|
||||
## 1. 追加两个流程节点和一条箭头
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "shape",
|
||||
"x": 80,
|
||||
"y": 100,
|
||||
"width": 160,
|
||||
"height": 72,
|
||||
"geometry": "dml:roundRect",
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "center",
|
||||
"runs": [{ "text": "读取需求", "marks": { "bold": true } }]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "finish",
|
||||
"type": "shape",
|
||||
"x": 360,
|
||||
"y": 100,
|
||||
"width": 160,
|
||||
"height": 72,
|
||||
"geometry": "dml:roundRect",
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "center",
|
||||
"runs": [{ "text": "输出结果", "marks": { "bold": true } }]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connector",
|
||||
"start": {
|
||||
"type": "node",
|
||||
"nodeRef": { "scope": "request", "id": "start" },
|
||||
"anchor": { "mode": "fixed", "side": "right" }
|
||||
},
|
||||
"end": {
|
||||
"type": "node",
|
||||
"nodeRef": { "scope": "request", "id": "finish" },
|
||||
"anchor": { "mode": "fixed", "side": "left" },
|
||||
"marker": { "catalogId": "arrow.filled" }
|
||||
},
|
||||
"routing": "straight"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 追加带渐变和阴影的卡片
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "styled-card",
|
||||
"type": "shape",
|
||||
"x": 80,
|
||||
"y": 260,
|
||||
"width": 260,
|
||||
"height": 120,
|
||||
"geometry": "dml:roundRect",
|
||||
"style": {
|
||||
"fill": {
|
||||
"type": "linearGradient",
|
||||
"angle": 35,
|
||||
"stops": [
|
||||
{ "offset": 0, "color": "#DBEAFE" },
|
||||
{ "offset": 100, "color": "#A7F3D0" }
|
||||
]
|
||||
},
|
||||
"stroke": {
|
||||
"paint": { "type": "solid", "color": "#2563EB" },
|
||||
"width": 2
|
||||
},
|
||||
"effects": [
|
||||
{
|
||||
"type": "shadow",
|
||||
"offsetX": 5,
|
||||
"offsetY": 7,
|
||||
"blur": 18,
|
||||
"color": "#0F172A",
|
||||
"opacity": 0.22
|
||||
}
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "center",
|
||||
"runs": [
|
||||
{
|
||||
"text": "复杂样式",
|
||||
"marks": { "fontSize": 20, "bold": true, "color": "#0F172A" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Frame 中放置分支流程
|
||||
|
||||
先创建 frame,再让子节点通过 `parentId` 引用 frame 的临时 ID。子节点坐标相对
|
||||
frame 左上角:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "pipeline",
|
||||
"type": "frame",
|
||||
"x": 60,
|
||||
"y": 440,
|
||||
"width": 720,
|
||||
"height": 300,
|
||||
"title": {
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"runs": [{ "text": "生成流水线", "marks": { "bold": true } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "branch-a",
|
||||
"type": "shape",
|
||||
"parentId": "pipeline",
|
||||
"x": 60,
|
||||
"y": 80,
|
||||
"width": 180,
|
||||
"height": 72,
|
||||
"geometry": "dml:rect"
|
||||
},
|
||||
{
|
||||
"id": "branch-b",
|
||||
"type": "shape",
|
||||
"parentId": "pipeline",
|
||||
"x": 420,
|
||||
"y": 80,
|
||||
"width": 180,
|
||||
"height": 72,
|
||||
"geometry": "dml:rect"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 上传 SVG 并追加 Vector
|
||||
|
||||
Vector 固定使用“上传 → 字段映射 → update → query”流程,并且所有命令使用同一个
|
||||
`DOC_NODE_ID`。
|
||||
|
||||
先上传 SVG。该命令只准备资源,不会插入文档正文:
|
||||
|
||||
```bash
|
||||
dws doc media upload \
|
||||
--node <DOC_NODE_ID> \
|
||||
--file ./icon.svg \
|
||||
--mime-type image/svg+xml \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
从成功输出取 `resourceId` 和 `resourceUrl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodeId": "<DOC_NODE_ID>",
|
||||
"resourceId": "resource-stable-id",
|
||||
"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id",
|
||||
"fileName": "icon.svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"size": 1024
|
||||
}
|
||||
```
|
||||
|
||||
写入 `whiteboard-vector.json`,其中 `resourceId` 原样映射到
|
||||
`resource.resourceId`,`resourceUrl` 映射到 `resource.url`:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "uploaded-vector",
|
||||
"type": "vector",
|
||||
"x": 80,
|
||||
"y": 80,
|
||||
"width": 160,
|
||||
"height": 160,
|
||||
"resource": {
|
||||
"kind": "managed",
|
||||
"resourceId": "resource-stable-id",
|
||||
"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
执行更新:
|
||||
|
||||
```bash
|
||||
dws whiteboard update \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--source ./whiteboard-vector.json \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
最后独立回读,不以 update 的成功响应替代验证:
|
||||
|
||||
```bash
|
||||
dws whiteboard query \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--format json
|
||||
```
|
||||
|
||||
禁止跨 nodeId 复用资源,也不要把本地 SVG 路径、独立文件节点 URL 或临时
|
||||
`uploadUrl` 写入 `resource.url`。
|
||||
|
||||
## 5. 整页替换或清空
|
||||
|
||||
把文件设为 `overwrite: true` 后,命令必须加 `--yes`:
|
||||
|
||||
```bash
|
||||
dws whiteboard update \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--source ./overwrite.json \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
清空整页:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": true,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仅当用户明确要求清空时使用。执行前必须 query、展示影响摘要并获得确认。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-doc
|
||||
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
|
||||
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、白板卡片、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -25,6 +25,7 @@ metadata:
|
||||
- 文档内容只用 `--content` / `--content-file`,不要写 `--markdown`。
|
||||
- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。
|
||||
- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。
|
||||
- `doc whiteboard insert`、`doc media upload` 属于远端写入;必须先获得用户确认,再加 `--yes`。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcuts(无专用脚本/recipe 时优先)
|
||||
@@ -66,6 +67,8 @@ metadata:
|
||||
| "导入本地文件为在线文档" | `dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`(详见 `references/doc/doc-import.md`) |
|
||||
| "查模板 / 套用模板创建文档" | `dws doc template list|search|apply`(详见 `references/doc.md` 模板管理) |
|
||||
| "保存 / 查看 / 回滚在线文字文档(adoc)版本" | `dws doc version save/list/revert` |
|
||||
| "在文档里创建空白板" | `dws doc whiteboard insert --node <nodeId> --yes --format json` |
|
||||
| "为白板上传 SVG/Vector 资源" | `dws doc media upload --node <nodeId> --file <path> --yes --format json` |
|
||||
|
||||
## 标准 SOP(必遵流程)
|
||||
|
||||
|
||||
@@ -482,6 +482,26 @@ dws doc version revert --node <DOC_ID> --version <N> --yes --format json # 3.
|
||||
| `import` | `documentUrl` / `documentName` / `documentType` | 导入完成后的在线文档地址和名称 |
|
||||
| `import`(中断后) | `taskId` | `import get` 的 `--task-id`(查询后取结果获取文档地址) |
|
||||
|
||||
## 白板卡片与白板媒体资源
|
||||
|
||||
创建文档内空白板前先确认目标文档,获得用户确认后执行:
|
||||
|
||||
```bash
|
||||
dws doc whiteboard insert --node <DOC_ID> --yes --format json
|
||||
```
|
||||
|
||||
返回的 `blockId` 用于 `doc block delete`,`whiteboardId` 是白板 partId,用于
|
||||
`dws whiteboard query/update`。两者不可混用。为白板 Vector/SVG 准备资源时:
|
||||
|
||||
```bash
|
||||
dws doc media upload --node <DOC_ID> --file ./icon.svg \
|
||||
--mime-type image/svg+xml --yes --format json
|
||||
```
|
||||
|
||||
上传与后续白板更新必须使用同一 nodeId;仅使用稳定返回的 `resourceId` 和
|
||||
`resourceUrl`,禁止使用临时 uploadUrl 或跨文档复用资源。白板内容协议与更新
|
||||
流程见 `dingtalk-misc` 的 `references/whiteboard.md`。
|
||||
|
||||
## 相关产品
|
||||
|
||||
- [wiki](../../dingtalk-wiki/references/wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-misc
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -30,6 +30,7 @@ metadata:
|
||||
| 日报 / 周报 / 月报 / 写日志 / 收件箱日志 / 发件箱日志 | 日志(日报/周报/月报)查询与按模版提交 | `dws report`(别名 `dws log`) | [report.md](references/report.md) |
|
||||
| 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) |
|
||||
| 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) |
|
||||
| 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) |
|
||||
|
||||
## 说明
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 钉钉文档内嵌白板
|
||||
|
||||
`dws whiteboard` 只操作已经存在于钉钉在线文档中的单页内嵌白板。创建白板卡片使用
|
||||
`dws doc whiteboard insert`;普通文档块仍使用 `dws doc block`。
|
||||
|
||||
OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需读取
|
||||
[协议索引](./whiteboard/open-nodes-v1.md);不要根据本页概要猜测节点字段或
|
||||
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
|
||||
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
|
||||
|
||||
## 定位白板
|
||||
|
||||
每次操作都需要真实的文档 `nodeId` 和白板 `partId`。缺少 `partId` 时先读取文档
|
||||
JSONML,查找 `cardType=hetu` 且 `metadata.id` 非空的 card;`uuid` 是 blockId,
|
||||
不能当作 partId。多个候选时必须让用户选择,不能取第一个。
|
||||
|
||||
```bash
|
||||
dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags card --format json
|
||||
```
|
||||
|
||||
## 读取
|
||||
|
||||
```bash
|
||||
dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json
|
||||
```
|
||||
|
||||
CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令不支持全局
|
||||
`--jq` 或 `--fields`。
|
||||
|
||||
## 更新
|
||||
|
||||
更新文件使用 OpenNodes V1 信封:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "text",
|
||||
"x": 40,
|
||||
"y": 40,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"runs": [{"text": "方案"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `overwrite=false`:追加,`nodes` 至少一个对象。
|
||||
- `overwrite=true`:整页重建,允许空数组;执行前必须先 query 保存当前内容。
|
||||
- 所有更新都是远端写入,必须先获得用户确认,再加 `--yes`。
|
||||
- Query 返回不能直接作为 update 输入;真实节点 ID 不能用于局部修改。
|
||||
|
||||
```bash
|
||||
dws whiteboard update --node <DOC_ID> --part-id <PART_ID> \
|
||||
--source ./whiteboard.json --yes --format json
|
||||
```
|
||||
|
||||
常用节点类型包括 `text`、`shape`、`frame`、`group`、`connector`、`vector`。
|
||||
节点可用请求内临时 `id` 建立 `parentId` 或 connector 引用;服务端负责完整字段、
|
||||
层级和枚举校验,未知字段会使整次更新失败。
|
||||
|
||||
## Vector / SVG 资源
|
||||
|
||||
本地 SVG 不能直接写入 OpenNodes。先上传为绑定到同一文档 nodeId 的资源:
|
||||
|
||||
```bash
|
||||
dws doc media upload --node <DOC_ID> --file ./icon.svg \
|
||||
--mime-type image/svg+xml --yes --format json
|
||||
```
|
||||
|
||||
将返回的 `resourceId` 和 `resourceUrl` 分别映射为 Vector resource 的
|
||||
`resourceId` 与 `url`。禁止使用临时 uploadUrl、跨 nodeId 复用或传本地路径。
|
||||
|
||||
## 创建和删除白板卡片
|
||||
|
||||
```bash
|
||||
dws doc whiteboard insert --node <DOC_ID> --yes --format json
|
||||
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes --format json
|
||||
```
|
||||
|
||||
insert 返回 `blockId` 和 `whiteboardId`。前者用于块删除,后者就是后续 whiteboard
|
||||
命令的 partId;两者不可混用。插入成功但回查暂未取到 partId 时,命令会返回
|
||||
`whiteboardId: null` 并提示稍后按 blockId 回查。
|
||||
@@ -0,0 +1,45 @@
|
||||
# OpenNodes V1(DWS 白板协议索引)
|
||||
|
||||
本目录承载 `dws whiteboard query/update` 使用的 OpenNodes V1 协议。协议按
|
||||
调用阶段拆分,Agent 只加载当前任务所需章节,避免一次性读取全部内容。
|
||||
|
||||
## DWS 使用规则
|
||||
|
||||
- 只通过 `dws whiteboard query/update` 读写白板。
|
||||
- 每次调用必须提供承载白板的文档 `--node` 和目标白板 `--part-id`。
|
||||
- DWS 当前只支持单页白板:命令没有 `--page-id`,update 文件禁止包含
|
||||
`pageId`。
|
||||
- `query` 不接收请求体;CLI 会把返回的 `resultJson` 解析成对象。
|
||||
- 白板命令不支持使用全局 `--jq` 或 `--fields` 过滤输出;传入任一参数都会报错,
|
||||
Agent 直接读取 CLI 返回的结构化 JSON。
|
||||
- `update --source` 使用 `overwrite + source` 信封;append 和 overwrite 都是
|
||||
远端写入,获得用户确认后必须通过 `--yes` 显式确认。
|
||||
- CLI 只预检 JSON、信封、版本和 `nodes` 数组等外层结构;节点字段、枚举、层级、
|
||||
引用和业务约束由白板服务完整校验。任一层失败都不会保留部分更新。
|
||||
- DWS 返回以 `success`、`nodeId`、`partId`、`resultJson` 和可选的
|
||||
`resultSummary` 为准。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 当前任务 | 必读章节 |
|
||||
|---|---|
|
||||
| 理解版本、兼容和命令语义 | [01-overview](open-nodes-v1/01-overview.md) |
|
||||
| 读取或解释 query 结果 | [02-query](open-nodes-v1/02-query.md) |
|
||||
| 构造 append、overwrite 或清空请求 | [03-update](open-nodes-v1/03-update.md) |
|
||||
| 写富文本、列表、链接、主题色、渐变或阴影 | [04-text-style](open-nodes-v1/04-text-style.md) |
|
||||
| 写 shape、便签、frame、group 或 connector | [05-shape-frame-group-connector](open-nodes-v1/05-shape-frame-group-connector.md) |
|
||||
| 写已上传 Vector、内置 Icon 或自由 Path | [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md) |
|
||||
| 处理错误、query 转写或判断 writeSupport | [07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md) |
|
||||
| 选择合法 geometry 或 icon catalogId | [08-catalogs](open-nodes-v1/08-catalogs.md) |
|
||||
|
||||
## 强制读取规则
|
||||
|
||||
- 使用 `shape.geometry` 前必须读取 [08-catalogs](open-nodes-v1/08-catalogs.md),
|
||||
不得猜测 geometry。
|
||||
- 使用 `icon.catalogId` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md)
|
||||
和 [08-catalogs](open-nodes-v1/08-catalogs.md)。
|
||||
- 使用 `path` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md),
|
||||
不得把它当作通用 SVG Path。
|
||||
- Query 结果不能直接作为 update source;转换前必须读取
|
||||
[03-update](open-nodes-v1/03-update.md) 和
|
||||
[07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md)。
|
||||
@@ -0,0 +1,57 @@
|
||||
# DWS OpenNodes V1 协议说明
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
> 协议版本:`schemaVersion = "1.0"`,`catalogVersion = "dml-v1"`。
|
||||
|
||||
## 1. 协议用途
|
||||
|
||||
OpenNodes 是 DWS 白板命令使用的语义节点协议,提供两类能力:
|
||||
|
||||
- `dws whiteboard query`:返回稳定、可理解的页面和节点数据。
|
||||
- `dws whiteboard update`:接收受约束的节点描述,以 `append` 或
|
||||
`overwrite` 模式修改白板。
|
||||
|
||||
调用方只应依赖本文声明的语义字段和行为:
|
||||
|
||||
- `query` 不修改白板。
|
||||
- `update` 全部成功或全部回滚,不返回中间状态。
|
||||
- 未声明的存储字段、类型名称和处理过程不属于协议承诺。
|
||||
|
||||
OpenNodes V1 支持的节点类型、字段和读写范围见第 7 节。
|
||||
|
||||
DWS 负责身份认证和权限校验。Vector 资源准备使用 `dws doc media upload`,
|
||||
具体流程见白板命令参考。
|
||||
|
||||
## 2. 版本与兼容原则
|
||||
|
||||
| 字段 | 当前值 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `schemaVersion` | `1.0` | 控制文档结构、节点字段和字段语义。 |
|
||||
| `catalogVersion` | `dml-v1` | 控制允许写入的 DML 几何、连接线标记和内置 icon 目录。 |
|
||||
|
||||
V1 采用严格校验:
|
||||
|
||||
- 必填字段缺失会失败。
|
||||
- 未声明字段会失败,不会被静默忽略。
|
||||
- query-only 字段出现在 update 中会以 `readOnlyField` 失败。
|
||||
- 不支持的节点类型、目录值或引用范围会失败。
|
||||
- `null` 不代表“使用默认值”;除非字段类型明确允许,否则会失败。
|
||||
|
||||
本文列出的请求枚举值都是协议字面量,调用方必须按文档中的大小写和拼写原样传入,
|
||||
不能自行转换或猜测。响应中未来可能增加可选字段,调用方应忽略不认识的响应字段。
|
||||
|
||||
调用方必须原样携带当前版本值。新增不兼容结构时应升级
|
||||
`schemaVersion`;修改 DML、marker 或 icon 目录时应评估并升级
|
||||
`catalogVersion`。
|
||||
|
||||
## 3. DWS 命令一览
|
||||
|
||||
| 命令 | 所需权限 | 效果 |
|
||||
| --- | --- | --- |
|
||||
| `dws whiteboard query --node ... --part-id ...` | 可查看白板 | 读取单页白板,不修改内容。 |
|
||||
| `dws whiteboard update --node ... --part-id ... --source ... --yes` | 可编辑白板 | 追加节点或整页重建;所有更新都需先取得用户确认。 |
|
||||
|
||||
DWS 当前只支持文字文档中已有的单页内嵌白板。命令不接收 `pageId`,也不提供
|
||||
创建页面、切换页面或按既有节点 ID 局部修改的能力。
|
||||
@@ -0,0 +1,288 @@
|
||||
# OpenNodes V1 — Query 请求、返回结构和节点公共字段
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 4. Query 协议
|
||||
|
||||
### 4.1 请求
|
||||
|
||||
```bash
|
||||
dws whiteboard query \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--format json
|
||||
```
|
||||
|
||||
`query` 不接收请求体或 `pageId`。`--node` 和 `--part-id` 的发现规则见
|
||||
[白板命令参考](../../whiteboard.md)。CLI 会把服务返回的 `resultJson` JSON
|
||||
字符串解析成对象。
|
||||
|
||||
### 4.2 返回结构
|
||||
|
||||
```ts
|
||||
interface OpenNodesDocument {
|
||||
schemaVersion: "1.0";
|
||||
catalogVersion: "dml-v1";
|
||||
pages: OpenPage[];
|
||||
}
|
||||
|
||||
interface OpenPage {
|
||||
id: string;
|
||||
nodes: OpenNode[];
|
||||
}
|
||||
```
|
||||
|
||||
DWS 当前只支持单页白板,因此 `pages` 固定包含一个页面。调用方仍应从返回值读取
|
||||
页面 `id`,但不能把它作为 `pageId` 传给 DWS 命令。
|
||||
|
||||
母版节点不会作为独立页面返回,而会合并到引用它的页面 `nodes` 中,并带有:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "master",
|
||||
"writeSupport": "readOnly",
|
||||
"unsupportedFeatures": ["node.source.master"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 节点公共字段
|
||||
|
||||
`type` 的完整公开枚举如下。前一组可由 update 创建,后一组仅供 query 返回:
|
||||
|
||||
```ts
|
||||
type WritableOpenNodeType =
|
||||
| "shape"
|
||||
| "text"
|
||||
| "connector"
|
||||
| "stickyNote"
|
||||
| "frame"
|
||||
| "group"
|
||||
| "vector"
|
||||
| "icon"
|
||||
| "path";
|
||||
|
||||
type ReadOnlyOpenNodeType =
|
||||
| "image"
|
||||
| "pdf"
|
||||
| "media"
|
||||
| "webLink"
|
||||
| "table"
|
||||
| "chart"
|
||||
| "uml"
|
||||
| "swimlane"
|
||||
| "mind"
|
||||
| "timer"
|
||||
| "placeholder"
|
||||
| "unknown";
|
||||
|
||||
type OpenNodeType = WritableOpenNodeType | ReadOnlyOpenNodeType;
|
||||
```
|
||||
|
||||
每个 query 节点都包含以下公共字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | 白板中真实、稳定的节点 ID。 |
|
||||
| `type` | `OpenNodeType` | OpenNodes 公开语义类型,取值见上方枚举。 |
|
||||
| `parentId` | `string?` | group/frame 父节点 ID。无父节点时省略。 |
|
||||
| `children` | `string[]?` | group/frame 的直接子节点 ID,由服务端推导。 |
|
||||
| `x`、`y` | `number` | 有父节点时相对父节点;否则相对页面。单位为 px。 |
|
||||
| `width`、`height` | `number` | 节点包围盒尺寸,单位为 px。连接线允许其中一个为 `0`。 |
|
||||
| `angle` | `number` | 归一化到 `[0, 360)` 的角度。 |
|
||||
| `absoluteBounds` | `OpenBounds` | 页面坐标系中的绝对包围盒。 |
|
||||
| `layer` | `background \| normal \| foreground` | 节点所在层。 |
|
||||
| `zIndex` | `number` | 同一父节点、同一 layer 内的非负顺序,值越小越靠后。 |
|
||||
| `hidden` | `boolean` | 节点是否隐藏。 |
|
||||
| `locked` | `boolean` | 节点是否锁定。 |
|
||||
| `source` | `page \| master` | 节点来自当前页面还是母版。 |
|
||||
| `writeSupport` | `readWrite \| readOnly` | 当前节点能否由 V1 update 表达。 |
|
||||
| `unsupportedFeatures` | `string[]?` | 只读原因;`readWrite` 节点省略。 |
|
||||
|
||||
公共结构和各节点分支定义如下;分支中的字段含义与写入限制见第 7 节:
|
||||
|
||||
```ts
|
||||
interface OpenPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface OpenBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
interface OpenNodeBase {
|
||||
id: string;
|
||||
type: OpenNodeType;
|
||||
parentId?: string;
|
||||
children?: string[];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
absoluteBounds: OpenBounds;
|
||||
layer: "background" | "normal" | "foreground";
|
||||
zIndex: number;
|
||||
hidden: boolean;
|
||||
locked: boolean;
|
||||
source: "page" | "master";
|
||||
writeSupport: "readWrite" | "readOnly";
|
||||
unsupportedFeatures?: string[];
|
||||
}
|
||||
|
||||
interface OpenShapeNode extends OpenNodeBase {
|
||||
type: "shape";
|
||||
geometry: `dml:${string}`;
|
||||
adjustments?: Record<string, number>;
|
||||
text?: OpenText;
|
||||
style?: OpenNodeStyle;
|
||||
}
|
||||
|
||||
interface OpenTextNode extends OpenNodeBase {
|
||||
type: "text";
|
||||
text: OpenText;
|
||||
style?: OpenNodeStyle;
|
||||
}
|
||||
|
||||
interface OpenConnectorNode extends OpenNodeBase {
|
||||
type: "connector";
|
||||
start: OpenConnectorEndpoint;
|
||||
end: OpenConnectorEndpoint;
|
||||
routing: OpenConnectorRouting;
|
||||
waypoints?: OpenPoint[];
|
||||
style?: OpenNodeStyle;
|
||||
resolvedPath: OpenResolvedConnectorPath;
|
||||
}
|
||||
|
||||
interface OpenStickyNoteNode extends OpenNodeBase {
|
||||
type: "stickyNote";
|
||||
text?: OpenText;
|
||||
style?: OpenNodeStyle;
|
||||
creator?: {
|
||||
displayName?: string;
|
||||
hasAvatar?: boolean;
|
||||
};
|
||||
tags?: Array<{
|
||||
id: string;
|
||||
text: string;
|
||||
background: OpenPaint;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface OpenFrameNode extends OpenNodeBase {
|
||||
type: "frame";
|
||||
title?: {
|
||||
text: OpenText;
|
||||
box: { width: number; height: number };
|
||||
};
|
||||
style?: OpenNodeStyle;
|
||||
presentationOrder?: number;
|
||||
resizeMode: "free" | "fixedAspectRatio";
|
||||
}
|
||||
|
||||
interface OpenGroupNode extends OpenNodeBase {
|
||||
type: "group";
|
||||
children: string[];
|
||||
}
|
||||
|
||||
interface OpenVectorNode extends OpenNodeBase {
|
||||
type: "vector";
|
||||
resource: OpenVectorResource;
|
||||
}
|
||||
|
||||
interface OpenIconNode extends OpenNodeBase {
|
||||
type: "icon";
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
interface OpenPathNode extends OpenNodeBase {
|
||||
type: "path";
|
||||
path: OpenPathData;
|
||||
style?: OpenNodeStyle;
|
||||
}
|
||||
|
||||
interface OpenReadOnlyNode extends OpenNodeBase {
|
||||
type: ReadOnlyOpenNodeType;
|
||||
writeSupport: "readOnly";
|
||||
unsupportedFeatures: string[];
|
||||
}
|
||||
|
||||
type OpenNode =
|
||||
| OpenShapeNode
|
||||
| OpenTextNode
|
||||
| OpenConnectorNode
|
||||
| OpenStickyNoteNode
|
||||
| OpenFrameNode
|
||||
| OpenGroupNode
|
||||
| OpenVectorNode
|
||||
| OpenIconNode
|
||||
| OpenPathNode
|
||||
| OpenReadOnlyNode;
|
||||
```
|
||||
|
||||
query 中 `icon.catalogId` 使用 `string`,是为了让无法映射到当前目录的既有图标仍可
|
||||
被诊断;update 只能传入 7.10 节 `OpenIconCatalogId` 中列出的值。
|
||||
|
||||
### 4.4 Query 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"pages": [
|
||||
{
|
||||
"id": "page",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "real-node-id",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"angle": 0,
|
||||
"absoluteBounds": {
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"angle": 0
|
||||
},
|
||||
"layer": "normal",
|
||||
"zIndex": 0,
|
||||
"hidden": false,
|
||||
"locked": false,
|
||||
"source": "page",
|
||||
"writeSupport": "readWrite",
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "left",
|
||||
"runs": [
|
||||
{
|
||||
"text": "Hello OpenNodes",
|
||||
"marks": {
|
||||
"fontSize": 16,
|
||||
"color": "#223344"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center",
|
||||
"padding": [2, 4],
|
||||
"plainText": "Hello OpenNodes",
|
||||
"writeSupport": "readWrite"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,249 @@
|
||||
# OpenNodes V1 — Update 信封、Append/Overwrite 和公共写入字段
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 5. Update 协议
|
||||
|
||||
### 5.1 请求信封
|
||||
|
||||
```ts
|
||||
interface OpenNodesUpdateRequest {
|
||||
overwrite?: boolean;
|
||||
source: {
|
||||
schemaVersion: "1.0";
|
||||
catalogVersion: "dml-v1";
|
||||
nodes: OpenNodeWrite[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
字段含义:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `overwrite` | 否 | `false` 或省略为 append;`true` 为 overwrite。 |
|
||||
| `source.schemaVersion` | 是 | 必须为 `1.0`。 |
|
||||
| `source.catalogVersion` | 是 | 必须为 `dml-v1`。 |
|
||||
| `source.nodes` | 是 | 本次创建的节点数组。append 至少一个;overwrite 允许空数组。 |
|
||||
|
||||
`source` 不能直接使用 query 返回的 `OpenNodesDocument`,也不接受 `pages`。
|
||||
DWS 不接受 `pageId`;传入会在 CLI 本地校验阶段失败。
|
||||
|
||||
V1 没有“按真实节点 ID patch 既有节点”的语义。`source.nodes` 中的每一项都会
|
||||
创建一个新节点,`id` 仅是本次请求内建立父子关系和连接线引用的临时 ID:
|
||||
append 是新增节点,overwrite 是整页删除后重新创建。
|
||||
|
||||
### 5.2 Append 与 Overwrite
|
||||
|
||||
> **注意:** `overwrite: true` 是破坏性操作,会删除当前白板页面的全部自有
|
||||
> 节点。调用前应先 query 并确认影响范围;`nodes: []` 会清空页面。不希望删除
|
||||
> 既有内容时,应使用 append。
|
||||
|
||||
| 行为 | append | overwrite |
|
||||
| --- | --- | --- |
|
||||
| `overwrite` | `false` 或省略 | `true` |
|
||||
| 空 `nodes` | 禁止 | 允许,用于清空当前页面 |
|
||||
| 当前页面旧节点 | 全部保留 | 删除页面自有节点后创建新节点 |
|
||||
| 母版节点 | 保留 | 保留 |
|
||||
| 页面级设置 | 保留 | 保留 |
|
||||
| 原子性 | 全部成功或全部回滚 | 全部成功或全部回滚 |
|
||||
|
||||
overwrite 会替换当前页面的全部自有节点,不要求旧节点本身可由 OpenNodes V1
|
||||
写入。因此页面中存在 image、PDF、复杂文本等只读节点,不会单独阻止清空页面。
|
||||
|
||||
overwrite 会在删除前执行安全预检;以下情况会拒绝执行:
|
||||
|
||||
- 节点被锁定:`lockedNode`。
|
||||
- 节点不允许被删除:`deleteForbidden`。
|
||||
- 页面包含无法安全保留或清理的关联数据:`unknownMetadataReference`。
|
||||
- 目标节点未能完整删除:`deleteFailed`。
|
||||
|
||||
与被删除节点绑定且有明确清理规则的关联数据会随节点清理;无法安全保留或清理的
|
||||
关联数据会使 overwrite 失败。
|
||||
|
||||
overwrite 只替换当前页面节点,不会重建页面,也不会修改主题等页面级设置。
|
||||
白板已有的主题会被保留;白板没有有效主题时也不会自动添加。调用方应使用
|
||||
已配置有效主题的白板,或者显式提供不依赖主题的颜色。
|
||||
|
||||
### 5.3 成功结果
|
||||
|
||||
```ts
|
||||
interface DWSWhiteboardUpdateResponse {
|
||||
success: true;
|
||||
nodeId: string;
|
||||
partId: string;
|
||||
resultJson: {
|
||||
mode: "append" | "overwrite";
|
||||
createdNodeIds: string[];
|
||||
idMap: Record<string, string>;
|
||||
deletedNodeCount: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `success` | `true` 表示本次 DWS 调用成功。 |
|
||||
| `nodeId` | 输入的文档节点 ID。 |
|
||||
| `partId` | 输入的白板标识。 |
|
||||
| `resultJson.mode` | 实际执行的模式。 |
|
||||
| `resultJson.createdNodeIds` | 按请求节点顺序返回真实节点 ID。 |
|
||||
| `resultJson.idMap` | 请求中显式临时 ID 到真实节点 ID 的映射。 |
|
||||
| `resultJson.deletedNodeCount` | append 恒为 `0`;overwrite 为删除的页面自有节点数。 |
|
||||
| `resultJson.message` | 供人阅读的结果摘要,不应作为机器判断依据。 |
|
||||
|
||||
响应可能增加其他可选字段;Agent 不应依赖本节未声明的字段。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"nodeId": "DOC_NODE_ID",
|
||||
"partId": "WHITEBOARD_PART_ID",
|
||||
"resultJson": {
|
||||
"mode": "append",
|
||||
"createdNodeIds": ["generated-title-id", "generated-body-id"],
|
||||
"idMap": {
|
||||
"title": "generated-title-id",
|
||||
"body": "generated-body-id"
|
||||
},
|
||||
"deletedNodeCount": 0,
|
||||
"message": "Created 2 Whiteboard nodes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Update 公共节点字段
|
||||
|
||||
V1 可写节点公共字段如下:
|
||||
|
||||
```ts
|
||||
interface OpenNodeWriteBase {
|
||||
id?: string;
|
||||
layer?: "background" | "normal" | "foreground";
|
||||
zIndex?: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface OpenChildNodeWriteBase extends OpenNodeWriteBase {
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface OpenSizedNodeWriteBase extends OpenChildNodeWriteBase {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle?: number;
|
||||
}
|
||||
|
||||
interface OpenShapeNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "shape";
|
||||
geometry: `dml:${string}`;
|
||||
text?: OpenTextWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenTextNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "text";
|
||||
text: OpenTextWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenConnectorNodeWrite extends OpenNodeWriteBase {
|
||||
type: "connector";
|
||||
start: OpenConnectorEndpointWrite;
|
||||
end: OpenConnectorEndpointWrite;
|
||||
routing: OpenConnectorRouting;
|
||||
waypoints?: OpenPoint[];
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenStickyNoteNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "stickyNote";
|
||||
text?: OpenTextWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
interface OpenFrameNodeWrite extends OpenNodeWriteBase {
|
||||
type: "frame";
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle?: 0;
|
||||
title?: {
|
||||
text: OpenTextWrite;
|
||||
box?: { width: number; height: number };
|
||||
};
|
||||
style?: OpenNodeStyleWrite;
|
||||
presentationOrder?: number;
|
||||
resizeMode?: "free" | "fixedAspectRatio";
|
||||
}
|
||||
|
||||
interface OpenGroupNodeWrite extends OpenChildNodeWriteBase {
|
||||
id: string;
|
||||
type: "group";
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface OpenVectorNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "vector";
|
||||
resource: OpenManagedVectorResourceWrite;
|
||||
}
|
||||
|
||||
interface OpenIconNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "icon";
|
||||
catalogId: OpenIconCatalogId;
|
||||
}
|
||||
|
||||
interface OpenPathNodeWrite extends OpenSizedNodeWriteBase {
|
||||
type: "path";
|
||||
path: OpenPathDataWrite;
|
||||
style?: OpenNodeStyleWrite;
|
||||
}
|
||||
|
||||
type OpenNodeWrite =
|
||||
| OpenShapeNodeWrite
|
||||
| OpenTextNodeWrite
|
||||
| OpenConnectorNodeWrite
|
||||
| OpenStickyNoteNodeWrite
|
||||
| OpenFrameNodeWrite
|
||||
| OpenGroupNodeWrite
|
||||
| OpenVectorNodeWrite
|
||||
| OpenIconNodeWrite
|
||||
| OpenPathNodeWrite;
|
||||
```
|
||||
|
||||
上面的联合类型是 update 的字段白名单。各辅助结构和完整枚举值在第 7 节定义;
|
||||
未出现在对应分支中的字段不能发送。
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `id` | 可选的请求级临时 ID;非空、区分大小写、在请求内唯一。被引用时必须提供。 |
|
||||
| `type` | 必填,必须是 V1 可写类型。 |
|
||||
| `parentId` | 可选,引用同一请求中 group/frame 的临时 ID。 |
|
||||
| `x`、`y` | 除 connector 外必填;有父节点时为父节点相对坐标。 |
|
||||
| `width`、`height` | shape/text/stickyNote/frame/vector/icon/path 必填且大于 `0`;group/connector 只读。 |
|
||||
| `angle` | 可选,默认 `0`;frame 只允许 `0`;group/connector 只读。 |
|
||||
| `layer` | 可选;frame 默认 `background`,其他节点默认 `normal`。 |
|
||||
| `zIndex` | 可选的非负整数;相同值时按请求顺序稳定排序。 |
|
||||
| `hidden` | 可选布尔值,默认 `false`。 |
|
||||
|
||||
以下 query 字段禁止写回:
|
||||
|
||||
`children`、`absoluteBounds`、`locked`、`source`、`writeSupport`、
|
||||
`unsupportedFeatures`。
|
||||
|
||||
关系规则:
|
||||
|
||||
- `parentId` 只能引用同一请求中的 group 或 frame。
|
||||
- frame 和 connector 必须是页面直属节点,不能带 `parentId`。
|
||||
- group 可以嵌套,也可以放在 frame 中。
|
||||
- `children` 始终由各子节点的 `parentId` 推导。
|
||||
- 父子关系不能成环。
|
||||
- group 必须有临时 `id`、至少两个直接子节点,且不能全部隐藏。
|
||||
@@ -0,0 +1,395 @@
|
||||
# OpenNodes V1 — 支持矩阵、富文本和样式
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 7. 节点类型
|
||||
|
||||
### 7.1 支持矩阵
|
||||
|
||||
| `type` | query | update | 主要字段 |
|
||||
| --- | --- | --- | --- |
|
||||
| `shape` | 支持 | 支持 | `geometry`、`text?`、`style?` |
|
||||
| `text` | 支持 | 支持 | `text`、`style?` |
|
||||
| `connector` | 支持 | 支持 | `start`、`end`、`routing`、`waypoints?`、`style?` |
|
||||
| `stickyNote` | 支持 | 支持 | `text?`、`style?` |
|
||||
| `frame` | 支持 | 支持 | `title?`、`style?`、`presentationOrder?`、`resizeMode?` |
|
||||
| `group` | 支持 | 支持 | 子关系通过 `parentId` 表达 |
|
||||
| `image` | 支持 | 只读 | 仅公共字段 |
|
||||
| `vector` | 支持 | 支持 | `resource` |
|
||||
| `icon` | 支持 | 支持 | `catalogId` |
|
||||
| `path` | 支持 | 支持 | `path`、`style?` |
|
||||
| `pdf` | 支持 | 只读 | 仅公共字段 |
|
||||
| `media` | 支持 | 只读 | 仅公共字段 |
|
||||
| `webLink` | 支持 | 只读 | 仅公共字段 |
|
||||
| `table` | 支持 | 只读 | 仅公共字段 |
|
||||
| `chart` | 支持 | 只读 | 仅公共字段 |
|
||||
| `uml` | 支持 | 只读 | 仅公共字段 |
|
||||
| `swimlane` | 支持 | 只读 | 仅公共字段 |
|
||||
| `mind` | 支持 | 只读 | 仅公共字段 |
|
||||
| `timer` | 支持 | 只读 | 仅公共字段 |
|
||||
| `placeholder` | 支持 | 只读 | 仅公共字段 |
|
||||
| `unknown` | 支持 | 只读 | 未识别或尚未定义独立语义的节点统一映射到此类型 |
|
||||
|
||||
表中标记为只读的类型仍会完整返回公共几何、层级和顺序信息,但 update 提交会以
|
||||
`nodeTypeUnsupported` 失败。
|
||||
|
||||
`timer`、`table`、`webLink` 不支持 V1 update。query 仅返回这些节点的公共几何、
|
||||
层级、顺序和诊断字段,不承诺完整业务字段。文字 run 中的 `link` 是富文本能力,
|
||||
不属于 `webLink` 节点,V1 支持读写。
|
||||
|
||||
`webLink` 只保留只读查询;OpenNodes V1 不支持创建或重建该节点。
|
||||
|
||||
### 7.2 Text
|
||||
|
||||
query 和 update 均支持普通段落、无序列表、有序列表、多 block、多 run 和文字
|
||||
链接:
|
||||
|
||||
```ts
|
||||
interface OpenTextRun {
|
||||
text: string;
|
||||
marks?: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strike?: boolean;
|
||||
color?: string;
|
||||
highlight?: string;
|
||||
};
|
||||
link?: { url: string };
|
||||
}
|
||||
|
||||
interface OpenTextBlock {
|
||||
type: "paragraph" | "bulletList" | "orderedList";
|
||||
horizontalAlign?: "left" | "center" | "right";
|
||||
runs: OpenTextRun[];
|
||||
}
|
||||
|
||||
interface OpenTextWrite {
|
||||
blocks: OpenTextBlock[];
|
||||
verticalAlign?: "top" | "center" | "bottom";
|
||||
padding?: number | [number, number];
|
||||
}
|
||||
|
||||
interface OpenText extends OpenTextWrite {
|
||||
plainText: string;
|
||||
writeSupport: "readWrite" | "readOnly";
|
||||
unsupportedFeatures?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `blocks.length >= 1`,每个 block 的 `type` 必须是 `paragraph`、
|
||||
`bulletList` 或 `orderedList`。
|
||||
- 每个 block 都必须满足 `runs.length >= 1`。
|
||||
- run 的 `text` 不能包含 `\r`、`\n`、U+2028 或 U+2029。换行和列表项使用
|
||||
独立 block 表达;每一段或每个列表项应写成一个 block,而不是把原始换行符
|
||||
放进单个 run。
|
||||
- 每个 `bulletList` / `orderedList` block 表示一个列表项;服务端会把连续且同类的
|
||||
block 解释为同一个列表中的多个列表项。
|
||||
- `link.url` 长度必须为 `1..2048`,不能包含控制字符、`<`、`>`。支持无 scheme
|
||||
的相对/裸链接,以及 `http`、`https`、`mailto`、`tel`、`dingtalk` scheme;
|
||||
`javascript:`、`data:` 等可执行或未知 scheme 会被拒绝。
|
||||
- 相邻且 URL 相同的 linked run 会呈现为同一个链接,同时保留各 run 自己的 marks。
|
||||
- `fontSize > 0`,padding 各项必须大于等于 `0`。
|
||||
- `plainText`、文本级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
|
||||
- text 节点必须提供 `text`;shape 和 stickyNote 的 `text` 可省略。
|
||||
- 受支持的 paragraph、列表、链接、多 run 都保持
|
||||
`writeSupport = "readWrite"`;未知 list style、非法链接、未支持的 block 或
|
||||
run marks 会令文本和所属节点变为 `readOnly`。
|
||||
|
||||
下面的文本会显示为两个段落,第一段由两个不同样式的 run 组成:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "left",
|
||||
"runs": [
|
||||
{
|
||||
"text": "OpenNodes ",
|
||||
"marks": { "fontSize": 18, "bold": true, "color": "#2563EB" }
|
||||
},
|
||||
{
|
||||
"text": "rich text",
|
||||
"marks": { "fontSize": 18, "italic": true, "color": "#0F172A" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "right",
|
||||
"runs": [
|
||||
{
|
||||
"text": "第二段",
|
||||
"marks": { "fontSize": 16, "underline": true, "color": "#047857" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center",
|
||||
"padding": [4, 8]
|
||||
}
|
||||
```
|
||||
|
||||
同一 `OpenTextWrite` 结构适用于独立 text 节点、shape 文本、stickyNote 文本和
|
||||
frame title。
|
||||
|
||||
下面三个 block 会生成两个无序列表项,其中第二项包含文字链接:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "bulletList",
|
||||
"runs": [{ "text": "准备输入数据" }]
|
||||
},
|
||||
{
|
||||
"type": "bulletList",
|
||||
"runs": [
|
||||
{
|
||||
"text": "查看钉钉文档",
|
||||
"marks": { "underline": true },
|
||||
"link": { "url": "https://alidocs.dingtalk.com" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "orderedList",
|
||||
"runs": [{ "text": "执行生成" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
颜色接受以下 CSS 形式:3/4/6/8 位十六进制、颜色名,以及
|
||||
`rgb()`、`rgba()`、`hsl()`、`hsla()`、`oklch()`、`lab()`、`lch()`、
|
||||
`color()`。字符串最长 128 字符,不能包含控制字符、`<`、`>` 或 `;`。
|
||||
这里只接受可独立解析的字面量;依赖外部样式上下文的 `var()`、`calc()`、
|
||||
`color-mix()` 等动态表达式不属于 V1。颜色名必须是标准 CSS named color,
|
||||
任意字母串不会被当成颜色。
|
||||
|
||||
### 7.3 Style
|
||||
|
||||
query 可表达:
|
||||
|
||||
- `none`、`solid`、`theme`、线性渐变、径向渐变和图片 paint。
|
||||
- shadow、blur 和 unknown effect。
|
||||
|
||||
V1 update 支持 `none`、`solid`、`theme`、线性渐变、九方向径向渐变,以及
|
||||
单个自定义 shadow。主题明暗参数和渐变色标位置使用 `0~100` 的百分比,
|
||||
不会归一化成 `0~1`:
|
||||
|
||||
```ts
|
||||
type OpenRadialGradientPosition =
|
||||
| "topLeft"
|
||||
| "topCenter"
|
||||
| "topRight"
|
||||
| "centerLeft"
|
||||
| "center"
|
||||
| "centerRight"
|
||||
| "bottomLeft"
|
||||
| "bottomCenter"
|
||||
| "bottomRight";
|
||||
|
||||
interface OpenColorStop {
|
||||
offset: number;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface OpenImagePaint {
|
||||
type: "image";
|
||||
resource: {
|
||||
kind: "managed" | "external" | "embedded" | "unresolved";
|
||||
resourceId?: string;
|
||||
};
|
||||
intrinsicWidth: number;
|
||||
intrinsicHeight: number;
|
||||
}
|
||||
|
||||
type OpenPaint =
|
||||
| { type: "none" }
|
||||
| { type: "solid"; color: string; opacity?: number }
|
||||
| {
|
||||
type: "theme";
|
||||
token: string;
|
||||
lumMod?: number;
|
||||
lumOff?: number;
|
||||
resolvedColor?: string;
|
||||
}
|
||||
| {
|
||||
type: "linearGradient";
|
||||
angle: number;
|
||||
stops: OpenColorStop[];
|
||||
}
|
||||
| {
|
||||
type: "radialGradient";
|
||||
position: OpenRadialGradientPosition | "custom";
|
||||
stops: OpenColorStop[];
|
||||
}
|
||||
| OpenImagePaint;
|
||||
|
||||
type OpenEffect =
|
||||
| {
|
||||
type: "shadow";
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
blur: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
| { type: "blur"; blur: number }
|
||||
| { type: "unknown" };
|
||||
|
||||
interface OpenNodeStyle {
|
||||
opacity?: number;
|
||||
fill?: OpenPaint;
|
||||
stroke?: {
|
||||
paint: OpenPaint;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
lineCap?: "butt" | "round" | "square";
|
||||
lineJoin?: "miter" | "round" | "bevel";
|
||||
};
|
||||
effects?: OpenEffect[];
|
||||
writeSupport: "readWrite" | "readOnly";
|
||||
unsupportedFeatures?: string[];
|
||||
}
|
||||
|
||||
interface OpenColorStopWrite {
|
||||
offset: number; // [0, 100],百分比
|
||||
color: string;
|
||||
opacity?: number; // [0, 1]
|
||||
}
|
||||
|
||||
type OpenPaintWrite =
|
||||
| { type: "none" }
|
||||
| { type: "solid"; color: string; opacity?: number }
|
||||
| {
|
||||
type: "theme";
|
||||
token: string;
|
||||
lumMod?: number; // [0, 100],默认 100
|
||||
lumOff?: number; // [0, 100],默认 0
|
||||
}
|
||||
| {
|
||||
type: "linearGradient";
|
||||
angle: number;
|
||||
stops: OpenColorStopWrite[];
|
||||
}
|
||||
| {
|
||||
type: "radialGradient";
|
||||
position: OpenRadialGradientPosition;
|
||||
stops: OpenColorStopWrite[];
|
||||
};
|
||||
|
||||
interface OpenShadowEffectWrite {
|
||||
type: "shadow";
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
blur: number;
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
interface OpenNodeStyleWrite {
|
||||
opacity?: number;
|
||||
fill?: OpenPaintWrite;
|
||||
stroke?: {
|
||||
paint: OpenPaintWrite;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
lineCap?: "butt" | "round" | "square";
|
||||
lineJoin?: "miter" | "round" | "bevel";
|
||||
};
|
||||
effects?: OpenShadowEffectWrite[];
|
||||
}
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- opacity 范围为 `[0, 1]`。
|
||||
- solid paint 的 `color` 与 `opacity` 在 query 后仍保持独立字段,不会合并为
|
||||
动态 CSS 表达式。
|
||||
- theme 的 `token` 必须能在当前白板主题中解析;
|
||||
`lumMod`、`lumOff` 范围均为 `[0, 100]`。token 不存在时在
|
||||
Request graph 阶段返回 `themeTokenNotFound`,不会写出部分节点。
|
||||
token 长度为 `1~64`,首尾不能有空白,也不能包含空白、控制字符、
|
||||
`<`、`>` 或 `;`。
|
||||
- query 的 theme paint 还会返回按当前白板主题计算出的 query-only
|
||||
`resolvedColor`。调用方写入时只传 `token/lumMod/lumOff`,不传
|
||||
`resolvedColor`。
|
||||
- 渐变必须至少有一个 stop;`offset` 范围为 `[0, 100]`,单位是百分比。
|
||||
- 线性渐变 `angle` 范围为 `[0, 360]`。
|
||||
- 径向渐变只接受上述九宫格位置。query 遇到九宫格以外的位置时返回
|
||||
`position: "custom"` 并将该节点标为只读。
|
||||
- `effects` 最多包含一个 `shadow`;`offsetX/offsetY` 必须是有限数,
|
||||
`blur >= 0`,shadow opacity 范围为 `[0, 1]`。
|
||||
- stroke width 和 dash 各项必须大于等于 `0`。
|
||||
- 一旦提供 stroke,`stroke.paint` 必填。
|
||||
- 样式级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
|
||||
- 能在当前白板主题中解析且明暗参数合法的 theme paint 可写。无法解析的主题
|
||||
token、越界的主题明暗参数、image paint、blur、unknown effect、叠加 effect
|
||||
或自定义径向位置会令节点只读;受支持的 theme、渐变和单个 shadow 本身不会
|
||||
令节点只读。
|
||||
|
||||
主题色示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"fill": {
|
||||
"type": "theme",
|
||||
"token": "ac3",
|
||||
"lumMod": 20,
|
||||
"lumOff": 80
|
||||
},
|
||||
"stroke": {
|
||||
"paint": {
|
||||
"type": "theme",
|
||||
"token": "sk1",
|
||||
"lumMod": 80,
|
||||
"lumOff": 20
|
||||
},
|
||||
"width": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
示例中的 `ac3`、`sk1` 只是主题 token 示例,不是所有白板都可用的全局枚举。
|
||||
调用方只能使用已确认可由当前白板主题解析的 token;无法确认时应改用
|
||||
`solid` 颜色。
|
||||
|
||||
DWS 不提供新增、修改或切换主题的命令。当前白板没有有效主题时,应改用
|
||||
`solid`。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"fill": {
|
||||
"type": "linearGradient",
|
||||
"angle": 35,
|
||||
"stops": [
|
||||
{ "offset": 0, "color": "#1677ff", "opacity": 0.4 },
|
||||
{ "offset": 100, "color": "#69b1ff" }
|
||||
]
|
||||
},
|
||||
"effects": [
|
||||
{
|
||||
"type": "shadow",
|
||||
"offsetX": 8,
|
||||
"offsetY": 8,
|
||||
"blur": 19,
|
||||
"color": "rgba(93,190,172,1)",
|
||||
"opacity": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
未传 `style` 时使用节点类型的默认样式。调用方如需稳定的视觉结果,应显式传入
|
||||
`fill` 和 `stroke`。
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# OpenNodes V1 — Shape、Text、Sticky note、Frame、Group 和 Connector
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
### 7.4 Shape
|
||||
|
||||
shape 必须提供 `geometry`,格式为 `dml:<name>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "shape-1",
|
||||
"type": "shape",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 160,
|
||||
"height": 100,
|
||||
"geometry": "dml:roundRect",
|
||||
"style": {
|
||||
"fill": {
|
||||
"type": "solid",
|
||||
"color": "#DCEEFF"
|
||||
},
|
||||
"stroke": {
|
||||
"paint": {
|
||||
"type": "solid",
|
||||
"color": "#225588"
|
||||
},
|
||||
"width": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
query 可能返回 `adjustments`,但 V1 update 不支持写入;带 adjustments 的
|
||||
shape 会标为只读。`dml-v1` 的完整 geometry 目录见附录 A。
|
||||
|
||||
### 7.5 Text node 与 Sticky note
|
||||
|
||||
text node 使用公共几何、必填 `text` 和可选 `style`。
|
||||
|
||||
stickyNote 使用公共几何、可选 `text` 和可选 `style`。省略 `text` 时创建空
|
||||
便签。query 还可能返回:
|
||||
|
||||
- `creator`:创建者展示信息。
|
||||
- `tags`:标签 ID、文本和背景 paint。
|
||||
|
||||
这两个字段是 query-only;存在 creator 或 tags 的 stickyNote 会被标为只读。
|
||||
|
||||
### 7.6 Frame
|
||||
|
||||
frame 的 update 结构见第 6 节 `OpenFrameNodeWrite`。
|
||||
|
||||
约束:
|
||||
|
||||
- frame 必须是页面直属节点,不能带 `parentId`。
|
||||
- angle 只允许 `0`。
|
||||
- `presentationOrder` 是非负整数,并且不能和已有或本次创建的 frame 冲突。
|
||||
- frame 的子节点通过子节点 `parentId` 引用 frame 临时 ID。
|
||||
- frame 不能包含 frame 或 connector。
|
||||
- frame 默认 layer 为 `background`。
|
||||
|
||||
### 7.7 Group
|
||||
|
||||
group 的写入字段只有公共字段中的 `id`、`parentId?`、`x`、`y`、`layer?`、
|
||||
`zIndex?` 和 `hidden?`。其 width、height、angle 和 children 都由服务端根据
|
||||
子节点推导。
|
||||
|
||||
group 必须:
|
||||
|
||||
- 提供临时 `id`。
|
||||
- 至少包含两个直接子节点。
|
||||
- 至少有一个直接子节点可见。
|
||||
|
||||
group 的任一子节点为只读时,query 会把 group 一并标为只读。
|
||||
|
||||
### 7.8 Connector
|
||||
|
||||
connector 的几何由端点和路由推导,因此 update 不能提供 `x`、`y`、
|
||||
`width`、`height` 或 `angle`。
|
||||
|
||||
query 的连接线结构如下:
|
||||
|
||||
```ts
|
||||
type OpenConnectorRouting =
|
||||
| "straight"
|
||||
| "polyline"
|
||||
| "curve"
|
||||
| "orthogonal";
|
||||
|
||||
interface OpenConnectorMarker {
|
||||
catalogId: string;
|
||||
}
|
||||
|
||||
type OpenConnectorAnchor =
|
||||
| {
|
||||
mode: "fixed";
|
||||
side: "top" | "right" | "bottom" | "left";
|
||||
position: OpenPoint;
|
||||
}
|
||||
| {
|
||||
mode: "fixed";
|
||||
side: "custom";
|
||||
position: OpenPoint;
|
||||
};
|
||||
|
||||
type OpenConnectorEndpoint =
|
||||
| {
|
||||
type: "point";
|
||||
point: OpenPoint;
|
||||
marker: OpenConnectorMarker;
|
||||
}
|
||||
| {
|
||||
type: "node";
|
||||
nodeRef: { scope: "document"; id: string };
|
||||
anchor: OpenConnectorAnchor;
|
||||
resolvedPoint: OpenPoint;
|
||||
marker: OpenConnectorMarker;
|
||||
};
|
||||
|
||||
interface OpenBezierSegment {
|
||||
start: OpenPoint;
|
||||
control1: OpenPoint;
|
||||
control2: OpenPoint;
|
||||
end: OpenPoint;
|
||||
}
|
||||
|
||||
type OpenResolvedConnectorPath =
|
||||
| { type: "polyline"; points: OpenPoint[] }
|
||||
| { type: "bezier"; segments: OpenBezierSegment[] };
|
||||
```
|
||||
|
||||
update 端点有两种形式:
|
||||
|
||||
```ts
|
||||
type OpenConnectorEndpointWrite =
|
||||
| {
|
||||
type: "point";
|
||||
point: { x: number; y: number };
|
||||
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
|
||||
}
|
||||
| {
|
||||
type: "node";
|
||||
nodeRef: { scope: "request"; id: string };
|
||||
anchor?:
|
||||
| { mode: "auto" }
|
||||
| {
|
||||
mode: "fixed";
|
||||
side: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
|
||||
};
|
||||
```
|
||||
|
||||
query 的 marker `catalogId` 使用 `string`,以便返回无法映射的既有 marker;
|
||||
update 只接受上面列出的 `"none"`、`"arrow.open"`、`"arrow.filled"`。
|
||||
|
||||
路由规则:
|
||||
|
||||
| `routing` | `waypoints` |
|
||||
| --- | --- |
|
||||
| `straight` | 禁止提供,包括空数组。 |
|
||||
| `polyline` | 必须至少提供一个。 |
|
||||
| `curve` | 可选。 |
|
||||
| `orthogonal` | 可选;显式点路径的相邻线段必须水平或垂直。 |
|
||||
|
||||
其他约束:
|
||||
|
||||
- 所有 point 和 waypoint 都使用页面绝对坐标。
|
||||
- node 端点只能引用同一请求中的 shape、text、stickyNote、frame、group 或 path。
|
||||
- node 端点不能引用隐藏节点、connector 或 query 中既有节点。
|
||||
- update 引用范围固定为 `scope: "request"`;query 返回的节点引用范围为
|
||||
`scope: "document"`,不能直接回写。
|
||||
- 同一连接线的两端不能引用同一个节点。
|
||||
- 零长度或无效路径会被拒绝。
|
||||
- marker 省略时默认为 `none`。
|
||||
- anchor 省略时按 `auto` 处理。
|
||||
|
||||
query 额外返回服务端解析后的:
|
||||
|
||||
- node 端点 `resolvedPoint`。
|
||||
- fixed anchor 的归一化 `position`。
|
||||
- `resolvedPath`,类型为 polyline points 或 cubic bezier segments。
|
||||
- 由真实路径推导的 `absoluteBounds`。
|
||||
|
||||
这些解析字段都是 query-only。
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
# OpenNodes V1 — Vector、Icon 和 Path
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
### 7.9 Vector(已上传 SVG/矢量资源)
|
||||
|
||||
`vector` 表示已经通过 `dws doc media upload` 获得稳定引用的 SVG/矢量图片。
|
||||
OpenNodes 只接收上传结果中的资源引用,不接收本地路径或原始 SVG/XML 内容。
|
||||
|
||||
上传时必须使用与后续白板更新相同的文档 `nodeId`:
|
||||
|
||||
```bash
|
||||
dws doc media upload \
|
||||
--node <DOC_NODE_ID> \
|
||||
--file ./icon.svg \
|
||||
--mime-type image/svg+xml \
|
||||
--format json
|
||||
```
|
||||
|
||||
将上传结果的 `resourceId` 和 `resourceUrl` 分别写入
|
||||
`resource.resourceId` 和 `resource.url`。
|
||||
|
||||
Update 必须提供完整的托管资源信息:
|
||||
|
||||
```ts
|
||||
interface OpenManagedVectorResourceWrite {
|
||||
kind: "managed";
|
||||
resourceId: string;
|
||||
url: string;
|
||||
}
|
||||
```
|
||||
|
||||
完整节点结构见第 6 节 `OpenVectorNodeWrite`。
|
||||
|
||||
`resource` 字段规则:
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| `kind` | 是 | 当前只允许固定值 `managed`,表示资源已经通过 DWS 上传。 |
|
||||
| `resourceId` | 是 | 资源稳定 ID,长度 1~256,只允许字母、数字、`.`、`_`、`:`、`-`。 |
|
||||
| `url` | 是 | 已上传资源地址,最长 4096;必须包含且只能包含一个同值的 `resourceId` 查询参数。 |
|
||||
|
||||
`url` 必须直接使用 `dws doc media upload` 返回的 `resourceUrl`,不得自行拼装
|
||||
或修改。
|
||||
|
||||
以下内容会被拒绝:
|
||||
|
||||
- 原始 SVG/XML、`data:`、`blob:`、`http:` URL。
|
||||
- `//host/path` 协议相对地址,以及自行构造或修改的其他相对地址。
|
||||
- 含空白、控制字符、反斜杠、fragment 或用户凭证的 URL。
|
||||
- URL 缺少 `resourceId`、重复出现 `resourceId`,或者 URL 中 ID 与显式
|
||||
`resource.resourceId` 不一致。
|
||||
- `resource` 缺失、字段不完整、`kind` 不是 `managed`,或包含未知字段。
|
||||
|
||||
完整 Append 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "uploaded-svg-1",
|
||||
"type": "vector",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 180,
|
||||
"angle": 0,
|
||||
"resource": {
|
||||
"kind": "managed",
|
||||
"resourceId": "0c1c94e1-f9af-4228-b32f-42bbd1555253",
|
||||
"url": "https://resources.example.com/assets/opaque-path?resourceId=0c1c94e1-f9af-4228-b32f-42bbd1555253"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"overwrite": false
|
||||
}
|
||||
```
|
||||
|
||||
示例中的 `resources.example.com` 是占位域名,实际调用必须使用
|
||||
`dws doc media upload` 返回的 `resourceUrl`。
|
||||
|
||||
`resourceId` 同时显式出现并包含在 URL 中,用于校验资源身份与地址是否一致。
|
||||
两者必须来自同一次 `dws doc media upload` 结果。
|
||||
|
||||
Query 的 `resource` 可能是:
|
||||
|
||||
```ts
|
||||
type OpenVectorResource =
|
||||
| { kind: "managed"; resourceId: string; url: string }
|
||||
| { kind: "external" | "embedded" | "unresolved" };
|
||||
```
|
||||
|
||||
- 能识别为托管资源的引用返回完整 `managed` 信息。
|
||||
- HTTP(S) 外链但不满足托管资源契约时返回 `external`。
|
||||
- `data:`/`blob:` 返回 `embedded`。
|
||||
- 其他缺失或无法识别的地址返回 `unresolved`。
|
||||
- 非 `managed` 资源会令节点只读,并分别产生
|
||||
`vector.resource.external`、`vector.resource.embedded` 或
|
||||
`vector.resource.unresolved`。
|
||||
|
||||
V1 vector update 不接受 `style`。既有节点包含 V1 无法表达的 fill、stroke、
|
||||
opacity、effect 或 adjustments 时,query 仍可读取资源和几何,但节点会标为只读。
|
||||
不影响资源内容的兼容性装饰不会单独令节点变为只读。
|
||||
|
||||
DWS 会校验资源引用。上传与 `whiteboard update` 必须使用同一个文档
|
||||
`nodeId`;不要跨文档复用资源,也不要使用临时 `uploadUrl`。
|
||||
|
||||
### 7.10 Icon(内置图标)
|
||||
|
||||
`icon` 表示内置图标。OpenNodes 使用版本化的 `catalogId` 作为稳定标识,
|
||||
允许值见下列类型定义和附录 B。
|
||||
|
||||
Update 结构:
|
||||
|
||||
```ts
|
||||
type OpenIconCatalogId =
|
||||
| `emoji/${
|
||||
| "happy"
|
||||
| "smile"
|
||||
| "laugh"
|
||||
| "fighting"
|
||||
| "like"
|
||||
| "ok"
|
||||
| "please"
|
||||
| "face-plam"
|
||||
| "tears-of-joy"
|
||||
| "cry"
|
||||
| "question"
|
||||
| "face-with-sweat"
|
||||
| "bloody-nose"
|
||||
| "doggy"}`
|
||||
| `tools/${
|
||||
| "pad"
|
||||
| "blue-note"
|
||||
| "yellow-notes"
|
||||
| "chart"
|
||||
| "chart-2"
|
||||
| "pencil"
|
||||
| "pen"
|
||||
| "bag"
|
||||
| "rocket"
|
||||
| "fire"
|
||||
| "gold"
|
||||
| "light"
|
||||
| "pin"
|
||||
| "red-flag"
|
||||
| "tea"
|
||||
| "island"
|
||||
| "ball"
|
||||
| "lucky-fish"
|
||||
| "coffee"
|
||||
| "milky-tea"
|
||||
| "pan"}`
|
||||
| `priority/priority-${1 | 2 | 3 | 4 | 5 | 6 | 7}`
|
||||
| `task/${
|
||||
| "task-start"
|
||||
| "task-oct"
|
||||
| "task-3oct"
|
||||
| "task-half"
|
||||
| "task-5oct"
|
||||
| "task-7oct"
|
||||
| "task-done"}`;
|
||||
```
|
||||
|
||||
完整节点结构见第 6 节 `OpenIconNodeWrite`。
|
||||
|
||||
完整 Append 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "pencil-icon",
|
||||
"type": "icon",
|
||||
"x": 100,
|
||||
"y": 80,
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"catalogId": "tools/pencil"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overwrite": false
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `catalogId` 必填,格式为 `<group>/<name>`,并且必须精确命中附录 B 的
|
||||
`dml-v1` allowlist;大小写、连字符和历史拼写都不能自行修正。
|
||||
- 当前目录有 `emoji`、`tools`、`priority`、`task` 四组,共 49 项。
|
||||
- update 只接受 `catalogId`,不接受 `group`、`name`、`style` 或 `resource`。
|
||||
- 内置 icon 不需要调用方上传资源,也不需要 `resourceId`。
|
||||
- 任意已上传 SVG 或自定义图标应使用 `vector`,并按 7.9 节提供完整
|
||||
`resource` 信息,不能伪造一个 icon `catalogId`。
|
||||
- icon 可以作为 group/frame 子节点;V1 connector 的 node 端点当前仍不接受
|
||||
icon 作为目标。
|
||||
|
||||
Query 返回相同的 `catalogId`。既有 icon 无法映射到当前目录时,节点仍会以
|
||||
`type: "icon"` 返回以便诊断,但 `writeSupport` 为 `readOnly`,原因包含
|
||||
`icon.catalogId`。非默认 opacity、filter/effect、text 或 adjustments 同样会令节点
|
||||
只读。
|
||||
|
||||
### 7.11 Path(自由画笔)
|
||||
|
||||
`path` 表示自由画笔轨迹。OpenNodes 保留两组互相独立的尺寸:
|
||||
|
||||
- 节点公共 `width` / `height` 是画布上的实际渲染尺寸,缩放节点时会变化。
|
||||
- `path.intrinsicWidth` / `path.intrinsicHeight` 是 SVG path 自身的坐标空间尺寸,
|
||||
表示 `path.data` 使用的内部坐标空间。
|
||||
|
||||
```ts
|
||||
interface OpenPathData {
|
||||
data: string;
|
||||
intrinsicWidth: number;
|
||||
intrinsicHeight: number;
|
||||
}
|
||||
|
||||
type OpenPathDataWrite = OpenPathData;
|
||||
```
|
||||
|
||||
完整 update 节点结构见第 6 节 `OpenPathNodeWrite`。query 和 update 的 `path`
|
||||
字段结构相同,但 update 仍必须满足下方命令子集和大小限制。
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "freehand-stroke",
|
||||
"type": "path",
|
||||
"x": 120,
|
||||
"y": 100,
|
||||
"width": 500,
|
||||
"height": 150,
|
||||
"path": {
|
||||
"data": "M0,75 Q50,0 100,75 Q150,150 200,75 Q250,0 300,75 Q350,150 400,75 Q450,0 500,75",
|
||||
"intrinsicWidth": 500,
|
||||
"intrinsicHeight": 150
|
||||
},
|
||||
"style": {
|
||||
"fill": { "type": "none" },
|
||||
"stroke": {
|
||||
"paint": { "type": "solid", "color": "#7C3AED" },
|
||||
"width": 10,
|
||||
"lineCap": "round",
|
||||
"lineJoin": "round"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
下面是一个仍然只使用 V1 命令子集、但包含 12 段二次贝塞尔曲线的蝴蝶轮廓。
|
||||
它显式回到起点,因此不需要使用尚未支持的 `Z`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "complex-butterfly-path",
|
||||
"type": "path",
|
||||
"x": 1500,
|
||||
"y": 1215,
|
||||
"width": 500,
|
||||
"height": 420,
|
||||
"path": {
|
||||
"data": "M250,180 Q210,105 145,70 Q55,25 35,110 Q10,195 125,220 Q35,275 80,355 Q125,415 210,315 Q235,285 250,250 Q265,285 290,315 Q375,415 420,355 Q465,275 375,220 Q490,195 465,110 Q445,25 355,70 Q290,105 250,180",
|
||||
"intrinsicWidth": 500,
|
||||
"intrinsicHeight": 420
|
||||
},
|
||||
"style": {
|
||||
"opacity": 0.96,
|
||||
"fill": {
|
||||
"type": "solid",
|
||||
"color": "#EDE9FE",
|
||||
"opacity": 0.72
|
||||
},
|
||||
"stroke": {
|
||||
"paint": {
|
||||
"type": "solid",
|
||||
"color": "#6D28D9"
|
||||
},
|
||||
"width": 8,
|
||||
"lineCap": "round",
|
||||
"lineJoin": "round"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
V1 path 写入约束如下:
|
||||
|
||||
- `data` 必须是一个绝对 `M`,后跟至少一个显式写出的绝对 `Q`;不接受相对命令,
|
||||
也不接受 `L`、`C`、`A`、`Z` 等通用 SVG 命令。
|
||||
- 所有命令参数必须完整且为有限数值;单节点 `data` 最长 1 MiB,最多 50,000 个
|
||||
命令。
|
||||
- `intrinsicWidth` 和 `intrinsicHeight` 必须为有限正数;它们不要求等于节点的
|
||||
`width` 和 `height`。
|
||||
- 未传 `style` 时,默认使用透明填充、`#222222` 描边、宽度 5,以及 round
|
||||
line cap/join。需要稳定视觉结果时仍应显式传入 `style`。
|
||||
- path 可以作为 group/frame 的子节点,也可以作为同一 update 请求中 connector
|
||||
的 node 端点。
|
||||
|
||||
query 会把其他能够解析的 SVG path 保留为 `type: "path"`,但标记为
|
||||
`readOnly`,原因包含 `path.commands`;超出上述 V1 限制时使用
|
||||
`path.data.size` 或 `path.commands.limit`。既有 path 带 text、adjustments、非
|
||||
`nonzero` fillRule 或 V1 无法表达的样式时也会只读。仅包含不影响画笔几何的
|
||||
兼容性信息时,不会因此变为只读。theme stroke 遵循通用 style 契约:query 会
|
||||
保留 token 和明暗参数;只要 token 能在当前白板主题中解析,就可以按 theme
|
||||
paint 回写。
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
# OpenNodes V1 — Update 示例、回写规则、错误模型和 writeSupport
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 8. Update 示例
|
||||
|
||||
### 8.1 Append 一个文本节点
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "left",
|
||||
"runs": [
|
||||
{
|
||||
"text": "Hello OpenNodes",
|
||||
"marks": {
|
||||
"fontSize": 16,
|
||||
"color": "#223344"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center",
|
||||
"padding": [2, 4]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Append 两个形状和一条引用连接线
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "left",
|
||||
"type": "shape",
|
||||
"x": 80,
|
||||
"y": 100,
|
||||
"width": 120,
|
||||
"height": 80,
|
||||
"geometry": "dml:roundRect"
|
||||
},
|
||||
{
|
||||
"id": "right",
|
||||
"type": "shape",
|
||||
"x": 360,
|
||||
"y": 100,
|
||||
"width": 120,
|
||||
"height": 80,
|
||||
"geometry": "dml:roundRect"
|
||||
},
|
||||
{
|
||||
"id": "line",
|
||||
"type": "connector",
|
||||
"start": {
|
||||
"type": "node",
|
||||
"nodeRef": {
|
||||
"scope": "request",
|
||||
"id": "left"
|
||||
},
|
||||
"anchor": {
|
||||
"mode": "fixed",
|
||||
"side": "right"
|
||||
}
|
||||
},
|
||||
"end": {
|
||||
"type": "node",
|
||||
"nodeRef": {
|
||||
"scope": "request",
|
||||
"id": "right"
|
||||
},
|
||||
"anchor": {
|
||||
"mode": "fixed",
|
||||
"side": "left"
|
||||
},
|
||||
"marker": {
|
||||
"catalogId": "arrow.filled"
|
||||
}
|
||||
},
|
||||
"routing": "straight"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Overwrite 整页
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": true,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "replacement",
|
||||
"type": "text",
|
||||
"x": 120,
|
||||
"y": 80,
|
||||
"width": 240,
|
||||
"height": 48,
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"runs": [
|
||||
{
|
||||
"text": "Replacement content"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 清空当前页面
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": true,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Query 数据不能直接回写
|
||||
|
||||
query 是完整可读投影,update 是受约束的创建协议,两者不是对称 JSON:
|
||||
|
||||
| Query 字段/能力 | Update 处理方式 |
|
||||
| --- | --- |
|
||||
| 真实 `id` | 只能作为请求级临时 ID;不能引用既有 document 节点。 |
|
||||
| `children` | 删除,通过子节点 `parentId` 重建。 |
|
||||
| `absoluteBounds` | 删除;普通节点使用 `x/y/width/height`,connector 使用端点和路由字段。 |
|
||||
| `locked`、`source`、`writeSupport`、`unsupportedFeatures` | 删除,均为 query-only。 |
|
||||
| 文本 `plainText` | 删除,由服务端根据 paragraph 和 run 重新计算。 |
|
||||
| 多 paragraph、列表、多 run、文字链接 | 可以保留;每个 block 必须是受支持类型,且 run 内不能包含原始换行符。 |
|
||||
| 未知 list style、非法链接或未支持的 block/marks | 需要移除或降级为受支持的 block/run。 |
|
||||
| theme paint | 保留 `token/lumMod/lumOff`,删除 query-only `resolvedColor`;token 必须能在当前白板主题中解析。 |
|
||||
| image paint | 需要降级成受支持的 paint,或不更新该节点。 |
|
||||
| 受支持的 linear/radial gradient、单个 shadow | 可以保留;gradient offset 使用 `0~100`,radial `custom` 不能回写。 |
|
||||
| blur、unknown 或叠加 effects | 需要删除、降级成单个 shadow,或不更新该节点。 |
|
||||
| connector `scope: "document"` | 不能回写;改为引用同一请求节点的 `scope: "request"`。 |
|
||||
| connector `resolvedPoint`、`position`、`resolvedPath` | 删除,均由服务端重新计算。 |
|
||||
| shape `adjustments` | V1 不支持写入。 |
|
||||
| stickyNote `creator`、`tags` | V1 不支持写入。 |
|
||||
|
||||
即使 query 节点显示 `writeSupport = "readWrite"`,update 仍会对版本、目录、
|
||||
字段和请求关系做完整校验。调用方不应跳过 update 错误处理。
|
||||
|
||||
## 10. 错误模型
|
||||
|
||||
### 10.1 顶层错误码
|
||||
|
||||
| 错误码 | 含义 |
|
||||
| --- | --- |
|
||||
| `invalidRequest.whiteboard.schemaInvalid` | JSON、字段或节点 schema 不合法。 |
|
||||
| `invalidRequest.whiteboard.catalogVersionUnsupported` | catalogVersion 不受支持。 |
|
||||
| `invalidRequest.whiteboard.validationFailed` | 节点间引用、父子关系或路径关系不合法。 |
|
||||
| `invalidRequest.whiteboard.emptySource` | append 的 nodes 为空。 |
|
||||
| `invalidRequest.whiteboard.overwriteUnsafe` | overwrite 安全预检失败。 |
|
||||
|
||||
### 10.2 DWS 错误输出
|
||||
|
||||
远端校验失败时,DWS 以统一 CLI 错误结构返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"category": "api",
|
||||
"reason": "business_error",
|
||||
"server_key": "whiteboard",
|
||||
"server_error_code": "invalidRequest.whiteboard.validationFailed",
|
||||
"message": "Whiteboard request graph is invalid",
|
||||
"trace_id": "TRACE_ID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
部分服务错误会使用更宽泛的 `invalidRequest.inputArgs.invalid`。Agent 应结合
|
||||
`server_error_code` 和 `message` 修正输入;需要排障时保留 `trace_id`。JSON 或
|
||||
信封级错误可能由 CLI 本地返回,不一定包含 `server_key` 和 `trace_id`。
|
||||
|
||||
校验类错误不可通过原样重试恢复。常见原因包括:
|
||||
|
||||
- Schema:缺少字段、未知字段、类型或枚举值错误、提交 query-only 字段、
|
||||
节点类型不支持。
|
||||
- 请求关系:临时 ID 重复、引用不存在、父子关系非法、连接线目标不支持、
|
||||
路径退化或主题 token 不存在。
|
||||
- Overwrite 预检:锁定节点、禁止删除、关联数据无法安全处理或删除失败。
|
||||
|
||||
任一阶段失败都不会保留部分更新。
|
||||
|
||||
## 11. writeSupport 的含义
|
||||
|
||||
`writeSupport` 表示当前 query 节点是否能由 V1 update 无损表达,不代表用户
|
||||
权限,也不代表 overwrite 是否允许移除该既有节点。
|
||||
|
||||
以下 `unsupportedFeatures` 均为对外返回的诊断枚举值:
|
||||
|
||||
- `node.source.master`、`node.locked`、`node.role`、`node.placeholder`、
|
||||
`node.extras`、`node.ability`。
|
||||
- `node.type.image`、`node.type.pdf`、`node.type.media`、
|
||||
`node.type.webLink`、`node.type.table`、`node.type.chart`、
|
||||
`node.type.uml`、`node.type.swimlane`、`node.type.mind`、
|
||||
`node.type.timer`、`node.type.placeholder`、`node.type.unknown`。
|
||||
- `text.list.unsupported`、`text.link.unsupported`、`text.block.unsupported`、
|
||||
`text.lineBreak.unsupported`、`text.marks.unsupported`、
|
||||
`text.color.unsupported`、`text.highlight.unsupported`。
|
||||
- `style.fill.color`、`style.fill.opacity`、`style.fill.theme.token`、
|
||||
`style.fill.theme.unresolved`、`style.fill.theme.modifier`、
|
||||
`style.fill.theme.opacity`、
|
||||
`style.fill.gradient.angle`、`style.fill.gradient.offset`、
|
||||
`style.fill.gradient.color`、`style.fill.gradient.opacity`、
|
||||
`style.fill.gradient.position`、`style.fill.image`。
|
||||
- `style.stroke.color`、`style.stroke.opacity`、`style.stroke.theme.token`、
|
||||
`style.stroke.theme.unresolved`、
|
||||
`style.stroke.theme.modifier`、`style.stroke.theme.opacity`、
|
||||
`style.stroke.gradient.angle`、`style.stroke.gradient.offset`、
|
||||
`style.stroke.gradient.color`、`style.stroke.gradient.opacity`、
|
||||
`style.stroke.gradient.position`、`style.stroke.image`。
|
||||
- `style.effects`。
|
||||
- `shape.adjustments`、`stickyNote.creator`、`stickyNote.tags`。
|
||||
- `vector.resource.external`、`vector.resource.embedded`、
|
||||
`vector.resource.unresolved`、`vector.fill`、`vector.stroke`、
|
||||
`vector.opacity`、`vector.effect`、`vector.adjustments`。
|
||||
- `icon.catalogId`、`icon.opacity`、`icon.effect`、`icon.text`、
|
||||
`icon.adjustments`。
|
||||
- `path.commands`、`path.data.size`、`path.commands.limit`、`path.text`、
|
||||
`path.fillRule`、`path.adjustments`。
|
||||
- `connector.parent`、`connector.marker.unsupported`、
|
||||
`connector.target.unexposed`、`connector.target.unsupported`、
|
||||
`connector.anchor.unresolved`、`connector.anchor.custom`、
|
||||
`connector.selfLoop`。
|
||||
- `group.angle`、`group.children.minimum`、`group.children.hidden`、
|
||||
`group.child.readOnly`。
|
||||
- `frame.angle`、`frame.child.frame`、`frame.child.readOnly`。
|
||||
|
||||
调用方应把 `unsupportedFeatures` 当作诊断信息,不应把当前枚举穷举写死为
|
||||
业务逻辑。真正可写与否以 `writeSupport` 和 update 校验结果为准。
|
||||
@@ -0,0 +1,61 @@
|
||||
# OpenNodes V1 — dml-v1 Geometry 和 Icon 完整目录
|
||||
|
||||
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
|
||||
> [协议索引](../open-nodes-v1.md)。
|
||||
|
||||
## 附录 A:dml-v1 geometry 目录
|
||||
|
||||
写入时在以下名称前加 `dml:`,例如 `rect` 写成 `dml:rect`。当前目录共
|
||||
183 项,目录版本由 `catalogVersion = "dml-v1"` 标识。
|
||||
|
||||
```text
|
||||
accentBorderCallout1 accentBorderCallout2 accentBorderCallout3
|
||||
accentCallout1 accentCallout2 accentCallout3 actionButtonBackPrevious
|
||||
actionButtonBeginning actionButtonBlank actionButtonDocument actionButtonEnd
|
||||
actionButtonForwardNext actionButtonHelp actionButtonHome
|
||||
actionButtonInformation actionButtonMovie actionButtonReturn actionButtonSound
|
||||
active allGeneralization arc attribute bentArrow bentUpArrow bevel bind blockArc
|
||||
borderCallout1 borderCallout2 borderCallout3 bracePair bracketPair callout1
|
||||
callout2 callout3 can chevron chord circularArrow cloud cloudCallout comment
|
||||
component control convert corner cube curvedDownArrow curvedLeftArrow
|
||||
curvedRightArrow curvedUpArrow dataStorage decagon delete diagStripe diamond
|
||||
dodecagon donut doubleWave downArrow downArrowCallout ellipse ellipseRibbon
|
||||
ellipseRibbon2 entity entitySet flowChartAlternateProcess flowChartCollate
|
||||
flowChartConnector flowChartDecision flowChartDelay flowChartDisplay
|
||||
flowChartDocument flowChartExtract flowChartInputOutput flowChartInternalStorage
|
||||
flowChartMagneticDisk flowChartMagneticDrum flowChartMagneticTape
|
||||
flowChartManualInput flowChartManualOperation flowChartMerge
|
||||
flowChartMultidocument flowChartOffpageConnector flowChartOnlineStorage
|
||||
flowChartOr flowChartPredefinedProcess flowChartPreparation
|
||||
flowChartPunchedCard flowChartPunchedTape flowChartSort
|
||||
flowChartSummingJunction flowChartTerminator foldedCorner frame generalization
|
||||
halfFrame heart heptagon hexagon history homePlate horizontalDivCircle
|
||||
horizontalScroll irregularSeal1 irregularSeal2 leftArrow leftArrowCallout
|
||||
leftBrace leftBracket leftRightArrow leftRightArrowCallout leftRightUpArrow
|
||||
leftUpArrow lightningBolt mathDivide mathEqual mathMinus mathMultiply
|
||||
mathNotEqual mathPlus moon multiClass multiValuedAttribute noSmoking node
|
||||
nonIsoscelesTrapezoid notchedRightArrow octagon parallelogram pentagon person
|
||||
pie plaque plus quadArrow quadArrowCallout receiveSignal rect relationship
|
||||
ribbon ribbon2 rightArrow rightArrowCallout rightBrace rightBracket round1Rect
|
||||
round2DiagRect round2SameRect roundRect rtTriangle smileyFace snip1Rect
|
||||
snip2DiagRect snip2SameRect snipRoundRect star10 star12 star16 star24 star32
|
||||
star4 star5 star6 star7 star8 stripedRightArrow sun teardrop triangle upArrow
|
||||
upArrowCallout upDownArrow user uturnArrow verticalDivCircle verticalScroll
|
||||
wave weakEntitySet weakRelationship wedgeEllipseCallout wedgeRectCallout
|
||||
wedgeRoundRectCallout
|
||||
```
|
||||
|
||||
## 附录 B:dml-v1 icon 目录
|
||||
|
||||
写入时必须使用完整的 `<group>/<name>`。当前共 4 组 49 项,目录版本由
|
||||
`catalogVersion = "dml-v1"` 标识。
|
||||
|
||||
| group | 数量 | name(组成 `group/name`) |
|
||||
| --- | ---: | --- |
|
||||
| `emoji` | 14 | `happy`、`smile`、`laugh`、`fighting`、`like`、`ok`、`please`、`face-plam`、`tears-of-joy`、`cry`、`question`、`face-with-sweat`、`bloody-nose`、`doggy` |
|
||||
| `tools` | 21 | `pad`、`blue-note`、`yellow-notes`、`chart`、`chart-2`、`pencil`、`pen`、`bag`、`rocket`、`fire`、`gold`、`light`、`pin`、`red-flag`、`tea`、`island`、`ball`、`lucky-fish`、`coffee`、`milky-tea`、`pan` |
|
||||
| `priority` | 7 | `priority-1`、`priority-2`、`priority-3`、`priority-4`、`priority-5`、`priority-6`、`priority-7` |
|
||||
| `task` | 7 | `task-start`、`task-oct`、`task-3oct`、`task-half`、`task-5oct`、`task-7oct`、`task-done` |
|
||||
|
||||
注意:`emoji/face-plam` 是 V1 保留的历史兼容枚举值,拼写虽然异常但属于协议值;
|
||||
传入 `emoji/face-palm` 会被拒绝。
|
||||
@@ -0,0 +1,308 @@
|
||||
# 钉钉白板常用 Recipes
|
||||
|
||||
以下各 Recipe 的 JSON 都写入本地文件,再通过 `dws whiteboard update --source <FILE.json>`
|
||||
传给白板。执行任何远端写入前,必须先向用户展示影响并取得明确确认;确认后
|
||||
才可添加 `--yes`:
|
||||
|
||||
```bash
|
||||
dws whiteboard update \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--source <FILE.json> \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
每次更新后都要再次执行 `dws whiteboard query ... --format json` 回读验证。
|
||||
|
||||
## 1. 追加两个流程节点和一条箭头
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "shape",
|
||||
"x": 80,
|
||||
"y": 100,
|
||||
"width": 160,
|
||||
"height": 72,
|
||||
"geometry": "dml:roundRect",
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "center",
|
||||
"runs": [{ "text": "读取需求", "marks": { "bold": true } }]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "finish",
|
||||
"type": "shape",
|
||||
"x": 360,
|
||||
"y": 100,
|
||||
"width": 160,
|
||||
"height": 72,
|
||||
"geometry": "dml:roundRect",
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "center",
|
||||
"runs": [{ "text": "输出结果", "marks": { "bold": true } }]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connector",
|
||||
"start": {
|
||||
"type": "node",
|
||||
"nodeRef": { "scope": "request", "id": "start" },
|
||||
"anchor": { "mode": "fixed", "side": "right" }
|
||||
},
|
||||
"end": {
|
||||
"type": "node",
|
||||
"nodeRef": { "scope": "request", "id": "finish" },
|
||||
"anchor": { "mode": "fixed", "side": "left" },
|
||||
"marker": { "catalogId": "arrow.filled" }
|
||||
},
|
||||
"routing": "straight"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 追加带渐变和阴影的卡片
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "styled-card",
|
||||
"type": "shape",
|
||||
"x": 80,
|
||||
"y": 260,
|
||||
"width": 260,
|
||||
"height": 120,
|
||||
"geometry": "dml:roundRect",
|
||||
"style": {
|
||||
"fill": {
|
||||
"type": "linearGradient",
|
||||
"angle": 35,
|
||||
"stops": [
|
||||
{ "offset": 0, "color": "#DBEAFE" },
|
||||
{ "offset": 100, "color": "#A7F3D0" }
|
||||
]
|
||||
},
|
||||
"stroke": {
|
||||
"paint": { "type": "solid", "color": "#2563EB" },
|
||||
"width": 2
|
||||
},
|
||||
"effects": [
|
||||
{
|
||||
"type": "shadow",
|
||||
"offsetX": 5,
|
||||
"offsetY": 7,
|
||||
"blur": 18,
|
||||
"color": "#0F172A",
|
||||
"opacity": 0.22
|
||||
}
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"horizontalAlign": "center",
|
||||
"runs": [
|
||||
{
|
||||
"text": "复杂样式",
|
||||
"marks": { "fontSize": 20, "bold": true, "color": "#0F172A" }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"verticalAlign": "center"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Frame 中放置分支流程
|
||||
|
||||
先创建 frame,再让子节点通过 `parentId` 引用 frame 的临时 ID。子节点坐标相对
|
||||
frame 左上角:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "pipeline",
|
||||
"type": "frame",
|
||||
"x": 60,
|
||||
"y": 440,
|
||||
"width": 720,
|
||||
"height": 300,
|
||||
"title": {
|
||||
"text": {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"runs": [{ "text": "生成流水线", "marks": { "bold": true } }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "branch-a",
|
||||
"type": "shape",
|
||||
"parentId": "pipeline",
|
||||
"x": 60,
|
||||
"y": 80,
|
||||
"width": 180,
|
||||
"height": 72,
|
||||
"geometry": "dml:rect"
|
||||
},
|
||||
{
|
||||
"id": "branch-b",
|
||||
"type": "shape",
|
||||
"parentId": "pipeline",
|
||||
"x": 420,
|
||||
"y": 80,
|
||||
"width": 180,
|
||||
"height": 72,
|
||||
"geometry": "dml:rect"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 上传 SVG 并追加 Vector
|
||||
|
||||
Vector 固定使用“上传 → 字段映射 → update → query”流程,并且所有命令使用同一个
|
||||
`DOC_NODE_ID`。
|
||||
|
||||
先上传 SVG。该命令只准备资源,不会插入文档正文:
|
||||
|
||||
```bash
|
||||
dws doc media upload \
|
||||
--node <DOC_NODE_ID> \
|
||||
--file ./icon.svg \
|
||||
--mime-type image/svg+xml \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
从成功输出取 `resourceId` 和 `resourceUrl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodeId": "<DOC_NODE_ID>",
|
||||
"resourceId": "resource-stable-id",
|
||||
"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id",
|
||||
"fileName": "icon.svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"size": 1024
|
||||
}
|
||||
```
|
||||
|
||||
写入 `whiteboard-vector.json`,其中 `resourceId` 原样映射到
|
||||
`resource.resourceId`,`resourceUrl` 映射到 `resource.url`:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": false,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "uploaded-vector",
|
||||
"type": "vector",
|
||||
"x": 80,
|
||||
"y": 80,
|
||||
"width": 160,
|
||||
"height": 160,
|
||||
"resource": {
|
||||
"kind": "managed",
|
||||
"resourceId": "resource-stable-id",
|
||||
"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
执行更新:
|
||||
|
||||
```bash
|
||||
dws whiteboard update \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--source ./whiteboard-vector.json \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
最后独立回读,不以 update 的成功响应替代验证:
|
||||
|
||||
```bash
|
||||
dws whiteboard query \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--format json
|
||||
```
|
||||
|
||||
禁止跨 nodeId 复用资源,也不要把本地 SVG 路径、独立文件节点 URL 或临时
|
||||
`uploadUrl` 写入 `resource.url`。
|
||||
|
||||
## 5. 整页替换或清空
|
||||
|
||||
把文件设为 `overwrite: true` 后,命令必须加 `--yes`:
|
||||
|
||||
```bash
|
||||
dws whiteboard update \
|
||||
--node <DOC_NODE_ID> \
|
||||
--part-id <WHITEBOARD_PART_ID> \
|
||||
--source ./overwrite.json \
|
||||
--yes \
|
||||
--format json
|
||||
```
|
||||
|
||||
清空整页:
|
||||
|
||||
```json
|
||||
{
|
||||
"overwrite": true,
|
||||
"source": {
|
||||
"schemaVersion": "1.0",
|
||||
"catalogVersion": "dml-v1",
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
仅当用户明确要求清空时使用。执行前必须 query、展示影响摘要并获得确认。
|
||||
+35
-3
@@ -1,7 +1,7 @@
|
||||
[root]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
|
||||
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent
|
||||
|
||||
[agoal]
|
||||
@@ -3247,7 +3247,7 @@
|
||||
[doc]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: +comment-create, +comment-list, +comment-reply, +copy, +doc-append, +export-get, +export-submit, +find-doc, +list, +move, +search, +share-doc, +template-list, +template-search, +version-list, +version-revert, +version-save, block, comment, create, export, file, import, info, media, read, template, update, version
|
||||
commands: +comment-create, +comment-list, +comment-reply, +copy, +doc-append, +export-get, +export-submit, +find-doc, +list, +move, +search, +share-doc, +template-list, +template-search, +version-list, +version-revert, +version-save, block, comment, create, export, file, import, info, media, read, template, update, version, whiteboard
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[doc.+comment-create]
|
||||
@@ -3443,7 +3443,7 @@
|
||||
[doc.media]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: download, insert
|
||||
commands: download, insert, upload
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[doc.media.download]
|
||||
@@ -3456,6 +3456,11 @@
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file:string|required=false|hidden=false|no-opt=""|scope=local, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, --file-path:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --index:int|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mime-type:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --name:string|required=false|hidden=false|no-opt=""|scope=local, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --ref-block:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --where:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[doc.media.upload]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file:string|required=false|hidden=false|no-opt=""|scope=local, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, --file-path:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mime-type:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --name:string|required=false|hidden=false|no-opt=""|scope=local, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
|
||||
|
||||
[doc.read]
|
||||
runnable: true
|
||||
hidden: false
|
||||
@@ -3509,6 +3514,17 @@
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[doc.whiteboard]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: insert
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[doc.whiteboard.insert]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --index:int|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --parent-block:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --ref-block:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --where:string|required=false|hidden=false|no-opt=""|scope=local, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
|
||||
|
||||
[doctor]
|
||||
runnable: true
|
||||
hidden: false
|
||||
@@ -5678,6 +5694,22 @@
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[whiteboard]
|
||||
runnable: true
|
||||
hidden: false
|
||||
commands: query, update
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[whiteboard.query]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --part-id:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
|
||||
|
||||
[whiteboard.update]
|
||||
runnable: true
|
||||
hidden: false
|
||||
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --part-id:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --source:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
|
||||
|
||||
[wiki]
|
||||
runnable: true
|
||||
hidden: false
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWhiteboardQuickExamplesUseWritableTextNodes(t *testing.T) {
|
||||
paths := []string{
|
||||
"../../skills/mono/references/products/whiteboard.md",
|
||||
"../../skills/multi/dingtalk-misc/references/whiteboard.md",
|
||||
}
|
||||
for _, path := range paths {
|
||||
path := path
|
||||
t.Run(filepath.Base(filepath.Dir(path))+"/"+filepath.Base(path), func(t *testing.T) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := firstJSONFence(t, string(data))
|
||||
source, _ := payload["source"].(map[string]any)
|
||||
nodes, _ := source["nodes"].([]any)
|
||||
if len(nodes) == 0 {
|
||||
t.Fatalf("quick example has no source.nodes: %#v", payload)
|
||||
}
|
||||
node, _ := nodes[0].(map[string]any)
|
||||
if node["type"] != "text" {
|
||||
t.Fatalf("quick example first node type = %#v, want text", node["type"])
|
||||
}
|
||||
for _, dimension := range []string{"width", "height"} {
|
||||
value, ok := node[dimension].(float64)
|
||||
if !ok || value <= 0 {
|
||||
t.Errorf("quick example %s = %#v, want positive number", dimension, node[dimension])
|
||||
}
|
||||
}
|
||||
text, ok := node["text"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("quick example text = %#v, want OpenNodes text object", node["text"])
|
||||
}
|
||||
blocks, _ := text["blocks"].([]any)
|
||||
if len(blocks) == 0 {
|
||||
t.Fatalf("quick example text.blocks = %#v, want non-empty array", text["blocks"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhiteboardRecipesAreDeliveredToBothSkillSurfaces(t *testing.T) {
|
||||
monoPath := "../../skills/mono/references/products/whiteboard/recipes.md"
|
||||
multiPath := "../../skills/multi/dingtalk-misc/references/whiteboard/recipes.md"
|
||||
mono, err := os.ReadFile(monoPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
multi, err := os.ReadFile(multiPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(mono) != string(multi) {
|
||||
t.Fatal("mono and multi whiteboard recipes differ")
|
||||
}
|
||||
content := string(mono)
|
||||
for _, required := range []string{
|
||||
"## 1. 追加两个流程节点和一条箭头",
|
||||
"## 2. 追加带渐变和阴影的卡片",
|
||||
"## 3. Frame 中放置分支流程",
|
||||
"## 4. 上传 SVG 并追加 Vector",
|
||||
"## 5. 整页替换或清空",
|
||||
"执行任何远端写入前,必须先向用户展示影响并取得明确确认",
|
||||
`"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"`,
|
||||
`"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"`,
|
||||
} {
|
||||
if !strings.Contains(content, required) {
|
||||
t.Errorf("whiteboard recipes missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, fence := range bashFences(content) {
|
||||
if strings.Contains(fence, "dws whiteboard update") || strings.Contains(fence, "dws doc media upload") {
|
||||
if !strings.Contains(fence, "--yes") {
|
||||
t.Errorf("whiteboard write example lacks --yes:\n%s", fence)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bashFences(markdown string) []string {
|
||||
const marker = "```bash\n"
|
||||
var fences []string
|
||||
for {
|
||||
start := strings.Index(markdown, marker)
|
||||
if start < 0 {
|
||||
return fences
|
||||
}
|
||||
markdown = markdown[start+len(marker):]
|
||||
end := strings.Index(markdown, "\n```")
|
||||
if end < 0 {
|
||||
return fences
|
||||
}
|
||||
fences = append(fences, markdown[:end])
|
||||
markdown = markdown[end+len("\n```"):]
|
||||
}
|
||||
}
|
||||
|
||||
func firstJSONFence(t *testing.T, markdown string) map[string]any {
|
||||
t.Helper()
|
||||
const marker = "```json\n"
|
||||
start := strings.Index(markdown, marker)
|
||||
if start < 0 {
|
||||
t.Fatal("markdown has no JSON fence")
|
||||
}
|
||||
start += len(marker)
|
||||
end := strings.Index(markdown[start:], "\n```")
|
||||
if end < 0 {
|
||||
t.Fatal("JSON fence is not closed")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(markdown[start:start+end]), &payload); err != nil {
|
||||
t.Fatalf("decode first JSON fence: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
Reference in New Issue
Block a user