mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
7 Commits
v1.0.67
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d2599080d | ||
|
|
519a600b62 | ||
|
|
d87d9b458a | ||
|
|
1173179b10 | ||
|
|
74d8458635 | ||
|
|
80fadf1801 | ||
|
|
c04da4723a |
@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
|
||||
@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
|
||||
73
shortcuts/base/record_json_shorthand_test.go
Normal file
73
shortcuts/base/record_json_shorthand_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, &cmdutil.Factory{})
|
||||
cmd, _, err := parent.Find([]string{name})
|
||||
if err != nil {
|
||||
t.Fatalf("Find(%s) error = %v", name, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// record-list 获得 --json 简写
|
||||
func TestRecordListRegistersJSONShorthand(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("+record-list missing --json shorthand")
|
||||
}
|
||||
if fl.Usage != "shorthand for --format json" {
|
||||
t.Errorf("usage = %q, want shorthand", fl.Usage)
|
||||
}
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
|
||||
t.Errorf("format default = %q, want markdown (unchanged)", def)
|
||||
}
|
||||
}
|
||||
|
||||
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
|
||||
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
cmdName string
|
||||
}{
|
||||
{"record-search", BaseRecordSearch, "+record-search"},
|
||||
{"record-get", BaseRecordGet, "+record-get"},
|
||||
} {
|
||||
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatalf("%s: --json (request body) missing", tc.name)
|
||||
}
|
||||
if strings.Contains(fl.Usage, "shorthand") {
|
||||
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
|
||||
}
|
||||
if fl.Value.Type() != "string" {
|
||||
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum 已接入:help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)")
|
||||
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
|
||||
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
|
||||
usage := cmd.Flags().Lookup("format").Usage
|
||||
if !strings.Contains(usage, "(markdown|json)") {
|
||||
t.Fatalf("format usage missing enum suffix: %q", usage)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func recordReadFormatFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "format",
|
||||
Default: "markdown",
|
||||
Enum: []string{"markdown", "json"},
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,6 +1027,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
}
|
||||
rctx.larkSDK = sdk
|
||||
|
||||
applyJSONShorthand(cmd, s)
|
||||
rctx.Format = rctx.Str("format")
|
||||
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
|
||||
return rctx, nil
|
||||
@@ -1172,6 +1173,75 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
|
||||
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
|
||||
}
|
||||
|
||||
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
|
||||
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
|
||||
// pretty-print switch or base +record-search's request-body payload).
|
||||
// Framework-injected flags never appear in s.Flags, so this cleanly separates
|
||||
// "self-declared json" from "injected shorthand".
|
||||
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "json" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
func shortcutFormatSupportsJSON(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
return slices.Contains(fl.Enum, "json")
|
||||
}
|
||||
}
|
||||
return true // framework-injected: json (default) | pretty | table | ndjson | csv
|
||||
}
|
||||
|
||||
// ensureJSONShorthand registers --json as a shorthand for --format json when:
|
||||
// 1. the command has a format flag (self-declared or framework-injected), AND
|
||||
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
|
||||
// 3. no flag named "json" is registered yet — pflag panics on duplicate
|
||||
// registration, and commands that declare their own --json (event
|
||||
// +subscribe, base +record-search/-get) keep their custom semantics.
|
||||
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
// A shortcut that declares its own "json" flag defines custom semantics
|
||||
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("format") == nil {
|
||||
return
|
||||
}
|
||||
if !shortcutFormatSupportsJSON(s) {
|
||||
return
|
||||
}
|
||||
// Safety net: pflag panics on duplicate registration.
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
return
|
||||
}
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
|
||||
// applyJSONShorthand folds the injected --json shorthand into the format flag
|
||||
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
|
||||
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
|
||||
// "json". An explicitly passed --format always wins over the shorthand (the
|
||||
// shorthand only fills in when the user did not choose a format). Shortcuts
|
||||
// that declare their own "json" flag keep its custom semantics untouched.
|
||||
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
|
||||
if shortcutDeclaresJSONFlag(s) {
|
||||
return
|
||||
}
|
||||
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
|
||||
return
|
||||
}
|
||||
if set, _ := cmd.Flags().GetBool("json"); set {
|
||||
_ = cmd.Flags().Set("format", "json")
|
||||
}
|
||||
}
|
||||
|
||||
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
|
||||
for _, fl := range s.Flags {
|
||||
desc := fl.Desc
|
||||
@@ -1235,10 +1305,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
if cmd.Flags().Lookup("json") == nil {
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
}
|
||||
}
|
||||
ensureJSONShorthand(cmd, s)
|
||||
if s.Risk == "high-risk-write" {
|
||||
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
|
||||
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
200
shortcuts/common/runner_json_shorthand_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
const jsonShorthandUsage = "shorthand for --format json"
|
||||
|
||||
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
s.Mount(parent, f)
|
||||
cmd, _, err := parent.Find([]string{s.Command})
|
||||
if err != nil {
|
||||
t.Fatalf("Find() error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
|
||||
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
// 默认输出格式不被改变
|
||||
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
|
||||
t.Errorf("format default = %q, want table", def)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但 Enum 不含 json → 不注册
|
||||
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+no-json", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format Enum lacks json")
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 format 但无 Enum(现状 triage 形态)→ 不注册(Enum 是判定依据)
|
||||
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "x", Command: "+legacy", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
if cmd.Flags().Lookup("json") != nil {
|
||||
t.Fatal("--json must NOT be registered when format has no Enum metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 json flag(subscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
|
||||
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("self-declared --json missing")
|
||||
}
|
||||
if fl.Usage != "pretty-print JSON instead of NDJSON" {
|
||||
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// parseMounted mounts the shortcut and parses args against the command's FlagSet
|
||||
// (registration side effects included), without executing RunE.
|
||||
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := mountTestShortcut(t, s)
|
||||
if err := cmd.ParseFlags(args); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", args, err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func customFormatShortcut() Shortcut {
|
||||
return Shortcut{
|
||||
Service: "mail", Command: "+fake-triage", Description: "x",
|
||||
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
}
|
||||
|
||||
// --json 单独使用 → format 归一化为 json
|
||||
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 --format 优先于 --json 简写:--format table --json → table
|
||||
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (explicit --format must win)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --format json --json → json(一致,无冲突)
|
||||
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
|
||||
t.Fatalf("format = %q, want json", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 均不传 → 默认值不变
|
||||
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
|
||||
s := customFormatShortcut()
|
||||
cmd := parseMounted(t, s, nil)
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
|
||||
t.Fatalf("format = %q, want table (default untouched)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 string 型 --json(record-search 形态:format+json 双声明)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "base", Command: "+fake-record-search", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
|
||||
{Name: "json", Desc: "request body JSON object"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
|
||||
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
|
||||
t.Fatalf("request-body --json corrupted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 自声明 bool 型 --json(subscribe 形态:无自定义 format,框架注入 format)→ 归一化跳过
|
||||
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
|
||||
s := Shortcut{
|
||||
Service: "event", Command: "+fake-subscribe", Description: "x",
|
||||
Flags: []Flag{
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
}
|
||||
cmd := parseMounted(t, s, []string{"--json"})
|
||||
applyJSONShorthand(cmd, &s)
|
||||
// 注入的 format 默认即 json;这里断言的是 Changed 状态未被归一化污染
|
||||
if cmd.Flags().Changed("format") {
|
||||
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
|
||||
}
|
||||
}
|
||||
|
||||
// 无自定义 format(普通命令)→ 注入默认 format + 简写(现状回归)
|
||||
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
|
||||
cmd := mountTestShortcut(t, Shortcut{
|
||||
Service: "im", Command: "+plain", Description: "x",
|
||||
Execute: func(context.Context, *RuntimeContext) error { return nil },
|
||||
})
|
||||
fl := cmd.Flags().Lookup("json")
|
||||
if fl == nil {
|
||||
t.Fatal("--json missing on default-format shortcut (regression)")
|
||||
}
|
||||
if fl.Usage != jsonShorthandUsage {
|
||||
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
|
||||
}
|
||||
}
|
||||
336
shortcuts/drive/drive_list_comments.go
Normal file
336
shortcuts/drive/drive_list_comments.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveListCommentsDefaultPageSize = 50
|
||||
driveListCommentsDefaultSolvedStatus = "false"
|
||||
driveListCommentsDefaultScope = "all"
|
||||
driveListCommentsDefaultUserIDType = "open_id"
|
||||
)
|
||||
|
||||
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
|
||||
|
||||
type driveListCommentsRef struct {
|
||||
Token string
|
||||
Type string
|
||||
SourceFlag string
|
||||
}
|
||||
|
||||
type driveListCommentsTarget struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
}
|
||||
|
||||
type driveListCommentsSpec struct {
|
||||
Ref driveListCommentsRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
SolvedStatus string
|
||||
CommentScope string
|
||||
NeedReaction bool
|
||||
NeedRelation bool
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
// DriveListComments lists document comments through the Drive comments API,
|
||||
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
|
||||
var DriveListComments = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+list-comments",
|
||||
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:document.comment:read"},
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
|
||||
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
|
||||
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
|
||||
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
|
||||
{Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}},
|
||||
{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
|
||||
{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
{Name: "user-id-type", Default: driveListCommentsDefaultUserIDType, Desc: "user ID type in the response", Enum: []string{"user_id", "union_id", "open_id"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveListCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDriveListCommentsSpec(spec)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveListCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDriveListCommentsSpec(spec); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveListCommentsDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveListCommentsSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDriveListCommentsSpec(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := buildDriveListCommentsParams(spec, target.FileType)
|
||||
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken))
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", path, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(buildDriveListCommentsOutput(target, data), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) {
|
||||
ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveListCommentsSpec{}, err
|
||||
}
|
||||
return driveListCommentsSpec{
|
||||
Ref: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")),
|
||||
CommentScope: strings.TrimSpace(runtime.Str("comment-scope")),
|
||||
NeedReaction: runtime.Bool("need-reaction"),
|
||||
NeedRelation: runtime.Bool("need-relation"),
|
||||
UserIDType: strings.TrimSpace(runtime.Str("user-id-type")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateDriveListCommentsSpec(spec driveListCommentsSpec) error {
|
||||
if spec.PageSize < 1 || spec.PageSize > 100 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
|
||||
}
|
||||
if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status")
|
||||
}
|
||||
if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope")
|
||||
}
|
||||
if spec.UserIDType != "user_id" && spec.UserIDType != "union_id" && spec.UserIDType != "open_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --user-id-type %q; allowed: user_id, union_id, open_id", spec.UserIDType).WithParam("--user-id-type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) {
|
||||
urlInput = strings.TrimSpace(urlInput)
|
||||
tokenInput = strings.TrimSpace(tokenInput)
|
||||
if urlInput != "" && tokenInput != "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
|
||||
}
|
||||
if urlInput == "" && tokenInput == "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
|
||||
}
|
||||
|
||||
raw := urlInput
|
||||
sourceFlag := "--url"
|
||||
if raw == "" {
|
||||
raw = tokenInput
|
||||
sourceFlag = "--token"
|
||||
}
|
||||
inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType)))
|
||||
|
||||
if ref, ok := common.ParseResourceURL(raw); ok {
|
||||
refType := normalizeDriveListCommentsType(ref.Type)
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(refType) {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
|
||||
sourceFlag,
|
||||
refType,
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
|
||||
}
|
||||
if inputType == "" {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(inputType) {
|
||||
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
|
||||
}
|
||||
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
func normalizeDriveListCommentsType(docType string) string {
|
||||
switch strings.TrimSpace(docType) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.TrimSpace(docType)
|
||||
}
|
||||
}
|
||||
|
||||
func driveListCommentsTypeSupported(docType string) bool {
|
||||
switch normalizeDriveListCommentsType(docType) {
|
||||
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) {
|
||||
if ref.Type != "wiki" {
|
||||
return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
map[string]interface{}{"token": ref.Token},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return driveListCommentsTarget{}, err
|
||||
}
|
||||
|
||||
node := common.GetMap(data, "node")
|
||||
objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type"))
|
||||
objToken := common.GetString(node, "obj_token")
|
||||
if objType == "" || objToken == "" {
|
||||
return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
|
||||
}
|
||||
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
|
||||
return driveListCommentsTarget{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
|
||||
objType,
|
||||
).WithParam(ref.SourceFlag)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil
|
||||
}
|
||||
|
||||
func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI {
|
||||
if spec.Ref.Type == "wiki" {
|
||||
params := buildDriveListCommentsParams(spec, "<obj_type from step 1>")
|
||||
if spec.NeedRelation {
|
||||
params["need_relation"] = "<sent only when obj_type is docx>"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve wiki -> list comments").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to underlying document").
|
||||
Params(map[string]interface{}{"token": spec.Ref.Token}).
|
||||
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments").
|
||||
Desc("[2] List comments on resolved document").
|
||||
Params(params)
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: list comments").
|
||||
GET("/open-apis/drive/v1/files/:file_token/comments").
|
||||
Params(buildDriveListCommentsParams(spec, spec.Ref.Type)).
|
||||
Set("file_token", spec.Ref.Token)
|
||||
}
|
||||
|
||||
func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
"page_size": spec.PageSize,
|
||||
"user_id_type": spec.UserIDType,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil {
|
||||
params["is_solved"] = *value
|
||||
}
|
||||
if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil {
|
||||
params["is_whole"] = *value
|
||||
}
|
||||
if spec.NeedReaction {
|
||||
params["need_reaction"] = true
|
||||
}
|
||||
if spec.NeedRelation && fileType == "docx" {
|
||||
params["need_relation"] = true
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func driveListCommentsSolvedStatusParam(status string) (*bool, bool) {
|
||||
switch strings.TrimSpace(status) {
|
||||
case "false", "":
|
||||
value := false
|
||||
return &value, true
|
||||
case "true":
|
||||
value := true
|
||||
return &value, true
|
||||
case "all":
|
||||
return nil, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func driveListCommentsScopeParam(scope string) (*bool, bool) {
|
||||
switch strings.TrimSpace(scope) {
|
||||
case "all", "":
|
||||
return nil, true
|
||||
case "whole":
|
||||
value := true
|
||||
return &value, true
|
||||
case "partial":
|
||||
value := false
|
||||
return &value, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
|
||||
items := common.GetSlice(data, "items")
|
||||
return map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": target.FileType,
|
||||
"items": items,
|
||||
"has_more": common.GetBool(data, "has_more"),
|
||||
"page_token": common.GetString(data, "page_token"),
|
||||
"count": len(items),
|
||||
}
|
||||
}
|
||||
265
shortcuts/drive/drive_list_comments_test.go
Normal file
265
shortcuts/drive/drive_list_comments_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestResolveDriveListCommentsInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
urlInput string
|
||||
token string
|
||||
docType string
|
||||
wantToken string
|
||||
wantType string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "url docx",
|
||||
urlInput: "https://example.larksuite.com/docx/docxToken?from=wiki",
|
||||
wantToken: "docxToken",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "token flag also accepts url",
|
||||
token: "https://example.larksuite.com/base/baseToken",
|
||||
wantToken: "baseToken",
|
||||
wantType: "bitable",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikiToken",
|
||||
docType: "wiki",
|
||||
wantToken: "wikiToken",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
urlInput: "https://example.larksuite.com/docx/docxToken",
|
||||
token: "docxToken",
|
||||
wantErr: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "bare token needs type",
|
||||
token: "docxToken",
|
||||
wantErr: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "type conflicts with url",
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiToken",
|
||||
docType: "docx",
|
||||
wantErr: "conflicts",
|
||||
},
|
||||
{
|
||||
name: "unsupported url type",
|
||||
urlInput: "https://example.larksuite.com/drive/folder/folderToken",
|
||||
wantErr: "unsupported",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveDriveListCommentsInput(tt.urlInput, tt.token, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Token != tt.wantToken || got.Type != tt.wantType {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDriveListCommentsParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
defaultSpec := driveListCommentsSpec{
|
||||
PageSize: 50,
|
||||
SolvedStatus: "false",
|
||||
CommentScope: "all",
|
||||
UserIDType: "open_id",
|
||||
}
|
||||
defaultParams := buildDriveListCommentsParams(defaultSpec, "docx")
|
||||
if got := defaultParams["is_solved"]; got != false {
|
||||
t.Fatalf("default is_solved = %#v, want false", got)
|
||||
}
|
||||
if _, ok := defaultParams["is_whole"]; ok {
|
||||
t.Fatalf("default params should omit is_whole: %#v", defaultParams)
|
||||
}
|
||||
if got := defaultParams["user_id_type"]; got != "open_id" {
|
||||
t.Fatalf("user_id_type = %#v, want open_id", got)
|
||||
}
|
||||
|
||||
allPartialSpec := driveListCommentsSpec{
|
||||
PageSize: 100,
|
||||
PageToken: "next",
|
||||
SolvedStatus: "all",
|
||||
CommentScope: "partial",
|
||||
NeedReaction: true,
|
||||
NeedRelation: true,
|
||||
UserIDType: "union_id",
|
||||
}
|
||||
allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx")
|
||||
if _, ok := allPartialParams["is_solved"]; ok {
|
||||
t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams)
|
||||
}
|
||||
if got := allPartialParams["is_whole"]; got != false {
|
||||
t.Fatalf("comment-scope partial is_whole = %#v, want false", got)
|
||||
}
|
||||
if got := allPartialParams["need_reaction"]; got != true {
|
||||
t.Fatalf("need_reaction = %#v, want true", got)
|
||||
}
|
||||
if got := allPartialParams["need_relation"]; got != true {
|
||||
t.Fatalf("need_relation = %#v, want true for docx", got)
|
||||
}
|
||||
if got := allPartialParams["page_token"]; got != "next" {
|
||||
t.Fatalf("page_token = %#v, want next", got)
|
||||
}
|
||||
|
||||
sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet")
|
||||
if _, ok := sheetParams["need_relation"]; ok {
|
||||
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxToken/comments",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("file_type"); got != "docx" {
|
||||
t.Errorf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := query.Get("is_solved"); got != "false" {
|
||||
t.Errorf("is_solved = %q, want false", got)
|
||||
}
|
||||
if got := query.Get("is_whole"); got != "" {
|
||||
t.Errorf("is_whole = %q, want omitted", got)
|
||||
}
|
||||
if got := query.Get("user_id_type"); got != "open_id" {
|
||||
t.Errorf("user_id_type = %q, want open_id", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{
|
||||
{"comment_id": "comment_1", "is_solved": false},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxToken",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxToken" {
|
||||
t.Fatalf("file_token = %q, want docxToken", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := data["count"]; got != float64(1) {
|
||||
t.Fatalf("count = %#v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("token"); got != "wikiToken" {
|
||||
t.Errorf("wiki token = %q, want wikiToken", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docxFromWiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/docxFromWiki/comments",
|
||||
OnMatch: func(req *http.Request) {
|
||||
query := req.URL.Query()
|
||||
if got := query.Get("is_solved"); got != "" {
|
||||
t.Errorf("is_solved = %q, want omitted for solved-status all", got)
|
||||
}
|
||||
if got := query.Get("is_whole"); got != "true" {
|
||||
t.Errorf("is_whole = %q, want true", got)
|
||||
}
|
||||
if got := query.Get("need_relation"); got != "true" {
|
||||
t.Errorf("need_relation = %q, want true for resolved docx", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []map[string]interface{}{},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveListComments, []string{
|
||||
"+list-comments",
|
||||
"--token", "wikiToken",
|
||||
"--type", "wiki",
|
||||
"--solved-status", "all",
|
||||
"--comment-scope", "whole",
|
||||
"--need-relation",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWiki" {
|
||||
t.Fatalf("file_token = %q, want docxFromWiki", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
}
|
||||
@@ -623,6 +623,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
|
||||
decision.Class = "rate_limited"
|
||||
decision.Terminal = true
|
||||
case problem.Code == 1062507:
|
||||
decision.Class = "parent_sibling_limit"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
|
||||
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
|
||||
@@ -1334,6 +1334,75 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll a: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1062507,
|
||||
"msg": "parent node out of sibling num.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
|
||||
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b" {
|
||||
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DrivePreview,
|
||||
DriveCover,
|
||||
DriveAddComment,
|
||||
DriveListComments,
|
||||
DriveExport,
|
||||
DriveExportDownload,
|
||||
DriveImport,
|
||||
|
||||
@@ -20,14 +20,15 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+download",
|
||||
"+preview",
|
||||
"+cover",
|
||||
"+add-comment",
|
||||
"+list-comments",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+version-history",
|
||||
"+version-get",
|
||||
"+version-revert",
|
||||
"+version-delete",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
"+export-download",
|
||||
"+import",
|
||||
"+move",
|
||||
"+delete",
|
||||
"+status",
|
||||
|
||||
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
112
shortcuts/mail/mail_json_shorthand_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// help 必须列出 --json 简写
|
||||
func TestMailTriageHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailTriage, []string{"+triage", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("triage help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcutWithCobraOutput(t, MailWatch, []string{"+watch", "-h"}, f, stdout); err != nil {
|
||||
t.Fatalf("help returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "shorthand for --format json") {
|
||||
t.Fatalf("watch help missing --json shorthand\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
|
||||
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage --json returned error: %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
if strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"messages"`) {
|
||||
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
|
||||
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
|
||||
f1, stdout1, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1", "--dry-run"}, f1, stdout1); err != nil {
|
||||
t.Fatalf("--json --dry-run error: %v", err)
|
||||
}
|
||||
f2, stdout2, _, _ := mailShortcutTestFactory(t)
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "json", "--max", "1", "--dry-run"}, f2, stdout2); err != nil {
|
||||
t.Fatalf("--format json --dry-run error: %v", err)
|
||||
}
|
||||
if stdout1.String() != stdout2.String() {
|
||||
t.Fatalf("dry-run outputs differ:\n--json:\n%s\n--format json:\n%s", stdout1.String(), stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级验证:显式 --format table 优先,--json 让位 → 仍走 table 路径
|
||||
func TestMailTriageExplicitTableWinsOverJSONShorthand(t *testing.T) {
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
registerTriageReadHintStubs(reg)
|
||||
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "table", "--json", "--max", "1"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("triage returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "tip: read full content:") {
|
||||
t.Fatalf("explicit --format table must win over --json (expected table hint)\nstderr=%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 错误验证:Enum 硬校验
|
||||
func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
|
||||
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "bogus", "--max", "1", "--dry-run"}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for --format bogus")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T, want typed errs problem carrier", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--format" {
|
||||
t.Fatalf("param = %q, want --format", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
|
||||
t.Fatalf("message = %q, want enum validation message", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "table, json, data") {
|
||||
t.Fatalf("message = %q, want allowed values list", problem.Message)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ var MailTriage = common.Shortcut{
|
||||
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
|
||||
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
|
||||
{Name: "page-size", Type: "int", Desc: "alias for --max"},
|
||||
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},
|
||||
|
||||
@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
|
||||
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
|
||||
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
|
||||
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
|
||||
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -21,10 +21,13 @@ metadata:
|
||||
## 快速决策
|
||||
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`。URL / token / wiki / 分页 / 评论状态等参数细节见 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
@@ -78,13 +81,14 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | `file_token` | 同添加评论 |
|
||||
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`;wiki URL/token 会自动解析到底层真实 token/type |
|
||||
|
||||
### 评论能力入口
|
||||
|
||||
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md):review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
|
||||
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
|
||||
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
|
||||
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段。
|
||||
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数。
|
||||
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
@@ -137,6 +141,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL;评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
|
||||
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL,支持 wiki 自动解包。 |
|
||||
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
|
||||
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
|
||||
@@ -162,7 +167,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
|
||||
>
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) 的模板通过 `--params` 传 `folder_token` / `page_token`,并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传参并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
|
||||
|
||||
### files
|
||||
|
||||
@@ -204,10 +209,12 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
### file.statistics
|
||||
|
||||
- `get` — 获取文件统计信息
|
||||
- 获取 docx / 文件统计信息时,建议优先使用 typed flags:`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json`;`--params` JSON 也支持,适合批量拼装或 raw 参数场景。
|
||||
|
||||
### file.view_records
|
||||
|
||||
- `list` — 获取文档的访问者记录
|
||||
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags:`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json`;`--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
|
||||
|
||||
### file.comment.reply.reactions
|
||||
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
# 文档评论定位字段
|
||||
|
||||
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本,或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`。
|
||||
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本,或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation`、`parent_type`、`parent_token` 等字段。
|
||||
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时,不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
|
||||
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时,不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
|
||||
|
||||
## 调用方式
|
||||
|
||||
分页列出评论时,把 `need_relation` 放在 query params:
|
||||
分页列出评论时,优先传 URL;Wiki URL / Wiki token 会自动解析到底层真实 token/type:
|
||||
|
||||
```bash
|
||||
lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
|
||||
```
|
||||
|
||||
如果只有 Wiki token,显式传 `--type wiki`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
|
||||
```
|
||||
|
||||
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.comments list \
|
||||
@@ -126,7 +138,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
|
||||
## 定位流程
|
||||
|
||||
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
|
||||
2. 用 `drive file.comments list` 或 `drive file.comments batch_query` 获取评论,并带 `need_relation=true`。
|
||||
2. 用 `drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`。raw `drive file.comments list` 仅作为低层参数兜底。
|
||||
3. 用 `docs +fetch --detail with-ids` 获取文档内容。
|
||||
4. 对每条评论先看 `relation`:
|
||||
- 如果存在 `relation.relation`,解析这个 JSON 字符串。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Drive 评论查询、统计与回复指南
|
||||
|
||||
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md),reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
|
||||
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md),获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md),reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
|
||||
|
||||
## 评论模式
|
||||
|
||||
@@ -16,14 +16,21 @@
|
||||
|
||||
## 查询默认口径
|
||||
|
||||
`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。
|
||||
优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。用户明确说“所有评论”“全部评论”“包含已解决评论”时,传 `--solved-status all`;只查已解决评论时传 `--solved-status true`。
|
||||
|
||||
```bash
|
||||
# 默认查询:仅未解决评论
|
||||
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}'
|
||||
lark-cli drive +list-comments --url '<DOC_URL>'
|
||||
|
||||
# 全部评论:包含已解决和未解决
|
||||
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status all
|
||||
|
||||
# 已解决评论
|
||||
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status true
|
||||
|
||||
# 裸 wiki token
|
||||
lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
|
||||
|
||||
# 包含已解决评论:仅当用户明确要求时使用
|
||||
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}'
|
||||
```
|
||||
|
||||
## 评论卡片与统计
|
||||
@@ -53,12 +60,13 @@ lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"doc
|
||||
## batch_query 与 list
|
||||
|
||||
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
|
||||
- `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论、获取最新或最后 N 条评论等场景。
|
||||
- `drive +list-comments` 用于分页获取评论列表,适合统计评论总数、遍历所有评论、获取最新或最后 N 条评论等场景;它会处理 URL、wiki token 和 token/type 匹配问题。
|
||||
- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。
|
||||
|
||||
## 评论定位字段
|
||||
|
||||
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block),先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。
|
||||
- 其他文档类型暂不支持返回定位字段。
|
||||
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block),先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`。
|
||||
- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略。
|
||||
|
||||
## 原生 API
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
|
||||
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token,或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
|
||||
|
||||
## 删除前门槛
|
||||
|
||||
执行 `drive +delete --yes` 前同时满足:
|
||||
|
||||
| 条件 | 可执行信号 |
|
||||
|------|------------|
|
||||
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token,或用户确认过且可解析的资源列表 |
|
||||
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
|
||||
|
||||
若缺少任一条件,使用 `drive +search`、`drive +inspect` 或只读 API 收集候选并回复待确认清单;启发式规则(打开时间、标题模式、owner、文件类型等)只能作为候选筛选依据,不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token` 和 `--type`。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -41,12 +41,37 @@ lark-cli drive files list \
|
||||
|
||||
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
|
||||
|
||||
## 按时间排序
|
||||
|
||||
默认不要传 `order_by` / `direction`;服务端会按默认顺序返回。只有用户明确要求按创建时间或编辑时间排序时,才使用服务端排序参数。
|
||||
|
||||
按创建时间升序列出当前文件夹直接子项:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","order_by":"CreatedTime","direction":"ASC","page_size":200}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
按编辑时间降序列出当前文件夹直接子项:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","order_by":"EditedTime","direction":"DESC","page_size":200}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
以上示例返回排序后的当前页;如果返回 `has_more=true`,保持相同 `folder_token` / `order_by` / `direction` / `page_size`,把 `next_page_token` 放入 `page_token` 继续翻页。
|
||||
|
||||
## 参数规则
|
||||
|
||||
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
|
||||
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
|
||||
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
|
||||
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
|
||||
3. 默认不要传 `order_by` / `direction`;只有用户明确要求按创建时间 / 编辑时间排序时才使用服务端排序参数。
|
||||
4. 排序参数映射:创建时间 -> `order_by:"CreatedTime"`;编辑时间 / 修改时间 -> `order_by:"EditedTime"`;升序 -> `direction:"ASC"`;降序 -> `direction:"DESC"`。不要省略排序参数后再用 Python / shell 客户端排序替代。
|
||||
5. 排序查询建议带 `page_size:200` 减少翻页;只有用户要求完整分页、递归盘点、大目录全量导出,或当前页返回 `has_more=true` 后继续翻页时,才加入 `page_token`。
|
||||
6. `page_size` 在分页、递归盘点或全量导出时建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
|
||||
7. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
|
||||
|
||||
## 返回结构与解析
|
||||
|
||||
|
||||
@@ -47,4 +47,6 @@ JSON 输出包含以下字段:
|
||||
- `--url` 为必填参数
|
||||
- 当 `--url` 是 bare token(非完整 URL)时,`--type` 也是必填的
|
||||
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type` 和 `token` 是底层文档的类型和 token
|
||||
- `+inspect` 只用于识别/消歧;如果任务已能通过 URL 路径形态完成路由判断,不必把它作为所有 Drive 操作的通用前置步骤
|
||||
- `+inspect` 失败后不要自动切到写接口继续尝试,先按错误提示处理权限、scope 或链接问题
|
||||
- 支持 `--dry-run` 查看将调用的 API 步骤
|
||||
|
||||
106
skills/lark-drive/references/lark-drive-list-comments.md
Normal file
106
skills/lark-drive/references/lark-drive-list-comments.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# drive +list-comments
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
|
||||
|
||||
列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传 URL,shortcut 会自动识别类型;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。原生 `drive file.comments list` 仍保留用于需要特殊参数的场景。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:直接传 URL。默认只查未解决评论。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>"
|
||||
|
||||
# 查询全部评论,包括已解决和未解决。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
--solved-status all
|
||||
|
||||
# 查询已解决评论。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
--solved-status true
|
||||
|
||||
# 只查全文评论或局部评论。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
--comment-scope whole
|
||||
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
--comment-scope partial
|
||||
|
||||
# wiki URL 会自动解包。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
|
||||
|
||||
# 裸 wiki token 也支持,但必须显式声明 --type wiki。
|
||||
lark-cli drive +list-comments \
|
||||
--token "<WIKI_TOKEN>" \
|
||||
--type wiki
|
||||
|
||||
# 裸真实 token 需要声明真实类型。
|
||||
lark-cli drive +list-comments \
|
||||
--token "<DOCX_TOKEN>" \
|
||||
--type docx \
|
||||
--page-size 100
|
||||
|
||||
# docx 需要评论定位关系时再带 need-relation;非 docx 会静默忽略。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
--need-relation
|
||||
|
||||
# 分页续跑。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
|
||||
--page-size 100 \
|
||||
--page-token "<NEXT_PAGE_TOKEN>"
|
||||
|
||||
# 预览请求链路,不发真实请求。
|
||||
lark-cli drive +list-comments \
|
||||
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URL;wiki URL 会自动解析到真实文档。 |
|
||||
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`;wiki token 使用 `--type wiki`。 |
|
||||
| `--type` | 裸 token 时必填 | `doc`、`docx`、`sheet`、`file`、`slides`、`bitable`、`base`、`wiki`。`base` 可作为 `bitable` 的别名使用。 |
|
||||
| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false`。`false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 |
|
||||
| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all`。`all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 |
|
||||
| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 |
|
||||
| `--need-relation` | 否 | docx 评论定位关系字段;仅 docx 生效,非 docx 静默忽略。需要定位正文时先读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 |
|
||||
| `--page-size` | 否 | 默认 50,最大 100。 |
|
||||
| `--page-token` | 否 | 分页游标;本 shortcut 不自动翻页,按返回的 `page_token` 继续请求下一页。 |
|
||||
| `--user-id-type` | 否 | `user_id` / `union_id` / `open_id`,默认 `open_id`。 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 默认 `--solved-status false`,即默认只查未解决评论。用户明确要“全部评论 / 包含已解决评论”时,传 `--solved-status all`。
|
||||
- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。
|
||||
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突,shortcut 会返回 validation error,建议移除 `--type`。
|
||||
- wiki 输入会自动解析到真实文档,再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
|
||||
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token`、`file_type`、`has_more`、`page_token`、`count`;`count` 是当前页返回的评论卡片数。
|
||||
- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query` 或 `drive file.comment.replys.*`。
|
||||
|
||||
## 输出
|
||||
|
||||
```json
|
||||
{
|
||||
"file_token": "docx_token",
|
||||
"file_type": "docx",
|
||||
"items": [],
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"count": 0
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
|
||||
- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明
|
||||
- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文
|
||||
@@ -10,6 +10,18 @@
|
||||
|
||||
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
|
||||
|
||||
## 公开权限修改前门槛
|
||||
|
||||
公开权限修改是高风险写操作。执行 `drive permission.public patch --yes` 前同时确认:
|
||||
|
||||
| 条件 | 可执行信号 |
|
||||
|------|------------|
|
||||
| 具体目标 | 单个 URL/token,或用户确认过的资源列表 |
|
||||
| 公开范围 | 用户明确选择组织内/互联网、可读/可编辑等具体 `link_share_entity` 档位 |
|
||||
| 执行确认 | 用户在本轮确认按该目标和范围执行 |
|
||||
|
||||
“开放一下”“共享给大家”“让大家能看”只表达目标状态,不包含具体公开范围。先列出可选范围并停止等待用户选择;公开档位必须来自用户选择,CLI 的 `--yes` 只表示已获得用户对该档位的执行确认。
|
||||
|
||||
## 公开权限错误码
|
||||
|
||||
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope;它们通常表示租户、对外分享或文档密级策略拦截。
|
||||
|
||||
@@ -143,6 +143,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试,检查目标文件夹权限、身份类型(user / bot)和资源可见性 |
|
||||
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
|
||||
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
|
||||
| `parent_sibling_limit` | `1062507` | 目标父文件夹单层子节点数量超过上限 | 停止重试,清理目标目录、换一个 `--folder-token`,或把上传内容拆到多个子目录 |
|
||||
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
|
||||
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# 知识整理工作流
|
||||
|
||||
This file is the single entry point for the knowledge organization workflow. It defines the global contract, state machine, and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
|
||||
Workflow id: `knowledge_organize`
|
||||
|
||||
Risk / Structure: `R2-R3` / `S3`
|
||||
|
||||
This file implements the registered knowledge organization workflow. Before execution, the agent MUST read [`lark-drive-workflow.md`](lark-drive-workflow.md) and [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md), and follow the shared execution protocol, Artifact Contract, Workflow Loading rules, authentication rules, and write confirmation rules.
|
||||
|
||||
It defines the workflow-specific state machine and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
|
||||
|
||||
Phase files are references for this workflow, not independent skills. Do not route user requests directly to a phase file.
|
||||
|
||||
@@ -80,7 +86,7 @@ When this workflow is triggered, the agent MUST:
|
||||
|
||||
## Runtime State
|
||||
|
||||
Agent MUST maintain these internal fields during one workflow run:
|
||||
This workflow extends the shared Artifact Contract. Agent MUST maintain these internal fields during one workflow run:
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
@@ -114,24 +120,24 @@ Agent MUST maintain these internal fields during one workflow run:
|
||||
|
||||
## Execution State Machine
|
||||
|
||||
| State | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|-----------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_SCOPE` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
|
||||
| `INVENTORY` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
|
||||
| `CONTENT_READ` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
|
||||
| `ISSUE_ANALYSIS` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
|
||||
| `RULE_GENERATION` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
|
||||
| `PLAN_GENERATION` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
|
||||
| `EXEC_CONFIRM` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
|
||||
| `VERIFY` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
|
||||
| `ROLLBACK_CONFIRM` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
|
||||
| `ROLLBACK` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
|
||||
| `ROLLBACK_VERIFY` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
|
||||
| `ROLLBACK_CLEANUP_CONFIRM` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
|
||||
| `ROLLBACK_CLEANUP` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
|
||||
| `ROLLBACK_CLEANUP_VERIFY` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
|
||||
| `DONE` | No more action | Stop | Final answer | `false` | End |
|
||||
| State | Protocol Step | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_SCOPE` | `route` / `scope` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
|
||||
| `INVENTORY` | `read` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
|
||||
| `CONTENT_READ` | `read` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
|
||||
| `ISSUE_ANALYSIS` | `assess` / `plan` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
|
||||
| `RULE_GENERATION` | `assess` / `plan` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
|
||||
| `PLAN_GENERATION` | `assess` / `plan` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
|
||||
| `EXEC_CONFIRM` | `confirm` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
|
||||
| `VERIFY` | `verify` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
|
||||
| `ROLLBACK_CONFIRM` | `recovery confirm` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
|
||||
| `ROLLBACK` | `recovery execute` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
|
||||
| `ROLLBACK_VERIFY` | `recovery verify` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
|
||||
| `ROLLBACK_CLEANUP_CONFIRM` | `cleanup confirm` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
|
||||
| `ROLLBACK_CLEANUP` | `cleanup execute` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
|
||||
| `ROLLBACK_CLEANUP_VERIFY` | `cleanup verify` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
|
||||
| `DONE` | `done` | No more action | Stop | Final answer | `false` | End |
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ Structure Level:
|
||||
2. Entry file 超过约 300 行时,优先拆 `commands`、`outputs` 或 `artifacts` reference。
|
||||
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
|
||||
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template,不默认新增独立 workflow。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;已发布的独立 `knowledge_organize` 是 `R2-R3/S3`,当前不作为本总框架 registry entry。
|
||||
5. 已有样板:`permission_governance` 是 `R2/S2`;`knowledge_organize` 是 `R2-R3/S3`。
|
||||
|
||||
## 加载与拆分边界
|
||||
|
||||
@@ -111,6 +111,7 @@ Structure Level:
|
||||
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|
||||
|----------|--------|------|-----------|------------|---------|
|
||||
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
|
||||
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
|
||||
|
||||
## Workflow Loading
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|
||||
|------|------|------|
|
||||
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
|
||||
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
|
||||
| `--format <mode>` | `table` | 输出样式:`table` / `json` / `data` |
|
||||
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
|
||||
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
|
||||
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
|
||||
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
@@ -64,11 +64,23 @@ Match asset type to slide role:
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
For `asset_type: "chart"`:
|
||||
|
||||
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
|
||||
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
|
||||
- Choose the data source explicitly:
|
||||
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
|
||||
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
|
||||
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
|
||||
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
|
||||
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Render a native `<chart>` using the user-provided series."
|
||||
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
@@ -118,7 +130,7 @@ Business comparison page:
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG** 或 **Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>` 和 `<shape>` 难以覆盖的视觉内容。
|
||||
|
||||
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
|
||||
|
||||
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
|
||||
|
||||
---
|
||||
@@ -12,13 +14,13 @@
|
||||
|
||||
| 场景 | 推荐元素 |
|
||||
|------|---------|
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
|
||||
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
|
||||
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
|
||||
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
|
||||
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高。
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑。
|
||||
|
||||
---
|
||||
|
||||
@@ -39,9 +41,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
## SVG 还是 Mermaid?
|
||||
|
||||
选择分两步:**先看图表类型,再看当前模型身份**。
|
||||
选择分三步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
|
||||
|
||||
### 第一步:图表类型优先判断
|
||||
### 第一步:先确认是否应该使用 `<chart>`
|
||||
|
||||
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
|
||||
|
||||
### 第二步:whiteboard 类型优先判断
|
||||
|
||||
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG:
|
||||
|
||||
@@ -50,20 +56,19 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
|
||||
| 时序图 | `sequenceDiagram` |
|
||||
| 类图 | `classDiagram` |
|
||||
| 饼图 | `pie` |
|
||||
| 甘特图 | `gantt` |
|
||||
| 状态图 | `stateDiagram-v2` |
|
||||
| 思维导图 | `mindmap` |
|
||||
| ER 图 | `erDiagram` |
|
||||
|
||||
### 第二步:数据图表与装饰元素按模型身份选路径
|
||||
### 第三步:非原生图表与装饰元素按模型身份选路径
|
||||
|
||||
上表以外的场景(散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等)需要精确控制坐标和配色,SVG 表达力更强,但各模型生成 SVG 的能力有差异:
|
||||
|
||||
| 模型身份 | 路径 |
|
||||
|----------|------|
|
||||
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `pie`、`gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `gantt`、`flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
|
||||
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
|
||||
|
||||
@@ -73,13 +78,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
### ⚠️ 设计品质要求
|
||||
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去。
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍。
|
||||
|
||||
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
|
||||
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或点
|
||||
- **非原生数据视觉必须有坐标系**:散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
|
||||
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
|
||||
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
|
||||
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
|
||||
- **装饰层次用亮度跳跃,不用线性叠透明度**:`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20);正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
|
||||
|
||||
@@ -106,11 +111,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
|
||||
| 元素 | 说明 | 典型用途 |
|
||||
|------|------|---------|
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
|
||||
| `<circle>` | 圆 | 节点、装饰点、环形图 |
|
||||
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
|
||||
| `<line>` | 直线 | 坐标轴、分隔线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、折线、弧形 |
|
||||
| `<line>` | 直线 | 轴线、分隔线、连接线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、曲线、弧形 |
|
||||
| `<text>` | 文本,支持中文 | 标签、数值 |
|
||||
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
|
||||
| `<g>` | 分组 | 批量变换、语义分组 |
|
||||
@@ -123,27 +128,25 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
---
|
||||
### 元素计算
|
||||
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用。
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`。
|
||||
|
||||
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
|
||||
|
||||
**数据图表(柱状图范式)**
|
||||
**散点图 / 装饰性点阵范式**
|
||||
|
||||
```python
|
||||
W, H = 360, 260
|
||||
origin_x, origin_y = 50, 216 # 左下角,SVG Y 轴向下
|
||||
cw, ch = 290, 184
|
||||
|
||||
data, y_max = [120, 160, 90], 200
|
||||
bar_w = int(cw / len(data) * 0.62)
|
||||
for i, v in enumerate(data):
|
||||
cx = round(origin_x + (i + 0.5) * cw / len(data))
|
||||
y = round(origin_y - v / y_max * ch)
|
||||
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
|
||||
points = [(12, 40), (28, 80), (45, 65)]
|
||||
x_min, x_max, y_min, y_max = 0, 50, 0, 100
|
||||
for i, (xv, yv) in enumerate(points):
|
||||
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
|
||||
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
|
||||
print(f"point-{i}: cx={x} cy={y}")
|
||||
```
|
||||
|
||||
折线图:`x = origin_x + i/(n-1)*cw`,`y = origin_y - (v-y_min)/(y_max-y_min)*ch`。
|
||||
|
||||
**装饰性元素(等间距范式)**
|
||||
|
||||
```python
|
||||
@@ -160,9 +163,9 @@ for i in range(n):
|
||||
```python
|
||||
# 每个元素登记 (x, y, w, h),含 stroke 外扩
|
||||
elements = [
|
||||
(10, 20, 80, 160), # bar-0
|
||||
(107, 10, 80, 170), # bar-1
|
||||
(204, 40, 80, 140), # bar-2
|
||||
(10, 20, 80, 160), # item-0
|
||||
(107, 10, 80, 170), # item-1
|
||||
(204, 40, 80, 140), # item-2
|
||||
(0, 0, 300, 1), # x-axis
|
||||
]
|
||||
|
||||
@@ -261,7 +264,6 @@ print(f"whiteboard width={wb_w} height={wb_h}")
|
||||
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
|
||||
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
|
||||
| 甘特图 | `gantt` | 项目计划、里程碑 |
|
||||
| 饼图 | `pie` | 占比数据 |
|
||||
| 类图 | `classDiagram` | 对象关系、架构设计 |
|
||||
| ER 图 | `erDiagram` | 数据库结构 |
|
||||
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
|
||||
@@ -279,7 +281,6 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|
||||
|---------|-----------|------------|
|
||||
| 流程图(5-8 节点) | 720-816 | 300-400 |
|
||||
| 时序图(3-5 参与者) | 720-816 | 320-420 |
|
||||
| 饼图 | 500-600 | 300-360 |
|
||||
| 甘特图 | 816 | 280-360 |
|
||||
| 思维导图 | 816 | 380-480 |
|
||||
|
||||
@@ -307,7 +308,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
|
||||
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size(避免被裁切)
|
||||
|
||||
**SVG 模式——视觉品质检查:**
|
||||
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
|
||||
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
|
||||
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
|
||||
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
|
||||
- [ ] 轴标签与图表元素互不遮挡,留有足够空间
|
||||
|
||||
@@ -119,6 +119,34 @@ Each slide must include:
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
Optional slide fields:
|
||||
|
||||
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_contract": {
|
||||
"required": true,
|
||||
"render_as": "native_chart",
|
||||
"chart_type": "line",
|
||||
"data_source": "mock_placeholder",
|
||||
"data_series_required": true,
|
||||
"placeholder_label_required": true,
|
||||
"manual_shape_fallback_allowed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
|
||||
|
||||
`data_source` must be one of:
|
||||
|
||||
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
|
||||
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
|
||||
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
|
||||
|
||||
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
@@ -183,6 +211,7 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
@@ -190,7 +219,7 @@ Good examples:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
|
||||
|
||||
## XML Generation Contract
|
||||
|
||||
@@ -201,6 +230,7 @@ Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
|
||||
1
skills/lark-slides/references/slides_chart_demo.xml
Normal file
1
skills/lark-slides/references/slides_chart_demo.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -3018,7 +3018,7 @@
|
||||
|
||||
子元素(mermaid 与 svg 二选一):
|
||||
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
|
||||
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
|
||||
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
|
||||
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
|
||||
示例: <mermaid><![CDATA[flowchart TD\n A[开始] --> B[结束]]]></mermaid>
|
||||
- svg: SVG 内容
|
||||
|
||||
@@ -263,7 +263,56 @@
|
||||
- `<chartLegend>`
|
||||
- `<chartTooltip>`
|
||||
|
||||
如果要写图表 XML,建议直接以 XSD 为准,不要自行发明更简化的 chart DSL。
|
||||
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例。
|
||||
|
||||
组合图示例(来自 [slides_chart_demo.xml](slides_chart_demo.xml)):
|
||||
|
||||
```xml
|
||||
<chart width="556" height="350" topLeftX="42" topLeftY="132">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="combo">
|
||||
<chartExtra/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1" comboType="column"/>
|
||||
<chartSeries index="2" comboType="line" yAxisPosition="right">
|
||||
<chartTooltip format="0%"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
<chartAxes>
|
||||
<chartAxis type="x">
|
||||
<chartLabel fontSize="10"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="left">
|
||||
<chartGridLine color="rgb(226, 232, 240)"/>
|
||||
<chartLabel fontSize="10"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="right">
|
||||
<chartLabel fontSize="10" format="0%"/>
|
||||
</chartAxis>
|
||||
</chartAxes>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="bottom" fontSize="11"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
|
||||
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(28, 71, 120)"/>
|
||||
<color value="rgb(240, 129, 54)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
```
|
||||
|
||||
## 样式元素
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
### whiteboard
|
||||
|
||||
```xml
|
||||
<!-- SVG 模式:数据图表、装饰元素 -->
|
||||
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
|
||||
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>
|
||||
|
||||
@@ -24,7 +24,7 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md),该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
|
||||
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow;该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
|
||||
- 用户给的是知识库 URL(`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。
|
||||
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL:**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
|
||||
- URL(`.../wiki/<token>`):`lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 31 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 32.3%
|
||||
- Denominator: 32 leaf commands
|
||||
- Covered: 11
|
||||
- Coverage: 34.4%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
@@ -12,7 +12,8 @@
|
||||
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, default `is_solved=false`, default omitted `is_whole`, `user_id_type=open_id`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
@@ -26,6 +27,7 @@
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
|
||||
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size`; `--user-id-type` | dry-run locks URL/token parsing, default unresolved filter, omitted all-scope filter, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
|
||||
@@ -81,4 +81,41 @@ func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
|
||||
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
|
||||
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
|
||||
}
|
||||
|
||||
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+list-comments",
|
||||
"--token", fileToken,
|
||||
"--type", "file",
|
||||
"--solved-status", "all",
|
||||
"--page-size", "100",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID)
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
|
||||
if got := gjson.Get(listResult.Stdout, "data.file_token").String(); got != fileToken {
|
||||
t.Fatalf("list data.file_token=%q, want %q\nstdout:\n%s", got, fileToken, listResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(listResult.Stdout, "data.file_type").String(); got != "file" {
|
||||
t.Fatalf("list data.file_type=%q, want file\nstdout:\n%s", got, listResult.Stdout)
|
||||
}
|
||||
if !driveCommentListContainsID(listResult.Stdout, commentID) {
|
||||
t.Fatalf("list comments did not include comment_id %q\nstdout:\n%s", commentID, listResult.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func driveCommentListContainsID(stdout, commentID string) bool {
|
||||
for _, item := range gjson.Get(stdout, "data.items").Array() {
|
||||
if item.Get("comment_id").String() == commentID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
100
tests/cli_e2e/drive/drive_list_comments_dryrun_test.go
Normal file
100
tests/cli_e2e/drive/drive_list_comments_dryrun_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+list-comments",
|
||||
"--url", "https://example.larksuite.com/docx/docxDryRunCommentList",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" {
|
||||
t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" {
|
||||
t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
isSolved := gjson.Get(out, "api.0.params.is_solved")
|
||||
if !isSolved.Exists() || isSolved.Bool() {
|
||||
t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
|
||||
}
|
||||
if gjson.Get(out, "api.0.params.is_whole").Exists() {
|
||||
t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
|
||||
t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
|
||||
t.Fatalf("api.0.params.user_id_type=%q, want open_id\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveListCommentsDryRun_WikiToken(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+list-comments",
|
||||
"--token", "wikiDryRunCommentList",
|
||||
"--type", "wiki",
|
||||
"--solved-status", "all",
|
||||
"--comment-scope", "partial",
|
||||
"--need-relation",
|
||||
"--page-size", "99",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
|
||||
t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
|
||||
t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
|
||||
t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if gjson.Get(out, "api.1.params.is_solved").Exists() {
|
||||
t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out)
|
||||
}
|
||||
isWhole := gjson.Get(out, "api.1.params.is_whole")
|
||||
if !isWhole.Exists() || isWhole.Bool() {
|
||||
t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
|
||||
t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user