Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
a0ca1fdb28 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
58dfbc5b6e |
@@ -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},
|
||||
|
||||
@@ -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 (必填)")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+11
-4
@@ -3657,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() {
|
||||
@@ -3728,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 的别名")
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user