Compare commits

..

20 Commits

Author SHA1 Message Date
fangshuyu
15263efe30 fix: normalize batch delete block IDs 2026-07-22 19:09:46 +08:00
fangshuyu
5b67085b32 fix: preserve docs title compatibility 2026-07-22 19:07:04 +08:00
fangshuyu
da149e66ba fix: validate unsafe docs write inputs 2026-07-22 18:52:59 +08:00
SunPeiYang996
80323bb464 docs: update lark doc HTML size limit (#2001) 2026-07-22 18:22:23 +08:00
YH-1600
0a33bd7c57 docs: add topic move collector workflow (#1473) 2026-07-22 17:45:33 +08:00
Yuxuan Zhao
aafaed06a7 fix(e2e): inject shared credentials by identity (#1995) 2026-07-22 17:43:25 +08:00
syh-cpdsss
54ddcf490b fix: remove legacy shortcut (#1997) 2026-07-22 15:33:40 +08:00
syh-cpdsss
bb246b591f fix: issue#1935 & whiteboard shortcut reformat (#1980) 2026-07-22 14:59:49 +08:00
calendar-assistant
fc2761d16b feat(calendar): auto-add bot self as attendee and note user-only search (#1991)
When creating an event as a bot, resolve the bot's own open_id via
/bot/v3/info and add it to the attendee list, mirroring how a user is
auto-joined to their own events; warn and proceed without it if the
lookup fails. Also note in the +create skill doc that the user-search
API is user-only, so resolving a name to open_id needs --as user.
2026-07-22 14:36:01 +08:00
syh-cpdsss
409a3172da feat: add okr single create shortcut & skill text opti (#1941)
* feat: add okr single create shortcut & skill text opti

* fix: deterministic-gate remove internal paging logic

* fix: CR issue

* opti: okr create/batch-create support note/category, indicator skill update
2026-07-22 14:16:54 +08:00
huarenmin13
483aadee3b fix(base): improve table shortcut behavior & guidance (#1803)
* fix(base): align table shortcut contracts

* fix(base): treat null record projection as omitted

1. Treat select_fields:null as omitted before record-get projection conflict checks.
2. Add dry-run E2E coverage for omitted and flag-projection cases.

```ai-signature
改动范围: shortcuts/base/record_ops.go 与 tests/cli_e2e/base/base_record_list_dryrun_test.go,仅调整 record-get 对 JSON null projection 的处理和回归验证
思考过程: 保持现有 projection normalizer 与互斥规则不变,只在读取 select_fields 后把 null 与缺失键等价,避免扩大到字段上限或 auto_number 行为
改动原因: PR 1803 声明 list search get 使用统一 projection contract,但 record-get 对 select_fields:null 仍返回 invalid_argument,与 record-search 不一致
Break Change: 否;仅将此前失败的 select_fields:null 输入规范化为省略,并保留 flag projection
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: b3d37c6c026f0215d994bc7c9bad4c65caee1b3bc2e9584ff20403a4d06969c3

* refactor(base): deduplicate Base dry-run E2E setup

1. Centralize Base dry-run environment setup, timeout handling, command execution,
    and exit-code assertions in runBaseDryRun.
2. Migrate record projection and field update dry-run tests without changing their contract assertio
    ns or covered scenarios.
3. Verify all 11 affected top-level tests and four projection subtests with the current-HEAD binary
    under race mode.

```ai-signature
改动范围: tests/cli_e2e/base/helpers_test.go、base_record_list_dryrun_test.go 与 base_field_update_dryrun_test.go,仅收敛 dry-run 测试执行脚手架
思考过程: 复用现有测试基础设施,把环境隔离、超时、dry-run 参数、命令执行和退出码断言集中到一个 helper,同时保留每个用例的业务断言
改动原因: PR 1803 的新增测试占主要改动量,其中 11 处重复执行模板可安全去重,降低评审体量而不削减 P1 或 P2 场景覆盖
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: ee39fef8497de65ecea1a0f22d9d87f1622c3f69daa5743ba7fd4c874dbb2ed3

---------

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
2026-07-21 23:22:48 +08:00
SunPeiYang996
e43f497650 docs: clarify fetch metadata and user cites (#1981) 2026-07-21 23:22:20 +08:00
SunPeiYang996
990d633c07 docs(skill): describe html5 block xml usage (#1380) 2026-07-21 22:26:01 +08:00
liangshuo-1
d4168ab84f chore: release v1.0.74 (#1990) 2026-07-21 21:19:43 +08:00
BD-ZERO
12ca42c953 fix(slides): clarify xml-text-overlap-lint error for positional argument (#1986)
* fix: xml_text_overlap_lint.py clarify XML lint input flag error
2026-07-21 20:29:02 +08:00
kongenpei
d382ee9053 feat(base): support per-record batch updates (#1889)
* feat(base): support per-record batch updates

* test(base): cover per-record batch updates

* test(base): make batch update assertions order-independent

* test(base): gate live batch updates on backend rollout

* test(base): keep live batch update coverage enabled

* fix(base): align per-record batch update response

* test(base): verify batch updates through effects

* docs(base): focus batch updates on update_records

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-07-21 20:17:59 +08:00
wangweiming-01
daaacb4977 docs: clarify drive upload overwrite guidance (#1982) 2026-07-21 19:27:03 +08:00
zhanghuanxu
680501c1df fix(slides): detect image text occlusion 2026-07-21 19:25:24 +08:00
zhanghuanxu
6675e3c247 fix(slides): exempt chart roundtrip attributes from lint 2026-07-21 17:16:57 +08:00
zhanghuanxu
7b48709438 fix(slides): warn on text shape overflow 2026-07-21 17:16:57 +08:00
94 changed files with 7185 additions and 1791 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.74] - 2026-07-21
### Features
- **slides**: add history rollback shortcuts (#1714)
- **base**: support per-record batch updates (#1889)
### Bug Fixes
- preserve slides schema issues
- allow jq examples in quality gate dry-runs
- **im**: warn when flag pagination is truncated (#1906)
- **slides**: warn on text shape overflow
- **slides**: exempt chart roundtrip attributes from lint
- **slides**: detect image text occlusion
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
### Documentation
- clarify drive upload overwrite guidance (#1982)
### Tests
- isolate unit tests from user state (#1883)
### Refactoring
- converge success output through a single Emitter that owns the write (#1899)
## [v1.0.73] - 2026-07-20
### Features
@@ -1579,6 +1608,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.73",
"version": "1.0.74",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -104,6 +104,22 @@ func TestDryRunFieldOps(t *testing.T) {
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
autoNumberRT := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"field-id": "fld_1",
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
},
nil,
nil,
)
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
}
}
func TestDryRunRecordOps(t *testing.T) {
@@ -117,7 +133,7 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,

View File

@@ -81,6 +81,37 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
return parent.ExecuteContext(context.Background())
}
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
t.Helper()
if err == nil {
t.Fatal("expected invalid-argument validation error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != wantParam {
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
}
if wantParams != nil {
if len(validationErr.Params) != len(wantParams) {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
for i, want := range wantParams {
if validationErr.Params[i].Name != want {
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
}
}
}
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
t.Fatalf("err=%v, want message containing %q", err, messageContains)
}
}
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
@@ -818,8 +849,189 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
t.Fatalf("stdout=%s", got)
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
}
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
tests := []struct {
name string
field interface{}
submitted map[string]interface{}
hintContains []string
}{
{
name: "direct complex server type overrides simple submitted type",
field: map[string]interface{}{"type": "auto_number"},
submitted: map[string]interface{}{"type": "number"},
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
},
{
name: "nested simple server type still recommends readback",
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
submitted: map[string]interface{}{"type": "auto_number"},
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
},
{
name: "submitted simple type still recommends readback when response omits type",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"type": "text"},
hintContains: []string{`type "text"`, "cannot determine the previous type"},
},
{
name: "missing type is conservative",
field: map[string]interface{}{"id": "fld_x"},
submitted: map[string]interface{}{"name": "Amount"},
hintContains: []string{"unknown or uncommon field type", "+field-get"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
t.Fatalf("result=%#v, want readback recommendation", got)
}
hint, _ := got["verification_hint"].(string)
for _, want := range tc.hintContains {
if !strings.Contains(hint, want) {
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
}
}
})
}
}
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 800070003,
"msg": "no operation produced",
},
})
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
if err == nil {
t.Fatal("expected the API no-op response to surface as an error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed API error, got %T %v", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if got := stdout.String(); strings.TrimSpace(got) != "" {
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
}
}
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
},
}
reg.Register(stub)
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
gotBody := string(stub.CapturedBody)
for _, want := range []string{
`"name":"编号"`,
`"type":"auto_number"`,
`"rules":[`,
`"date_format":"yyyyMM"`,
`"length":4`,
} {
if !strings.Contains(gotBody, want) {
t.Fatalf("request body missing %q:\n%s", want, gotBody)
}
}
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
if strings.Contains(gotBody, forbidden) {
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
}
}
got := stdout.String()
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
for _, forbidden := range []string{`"reformat_existing_records"`} {
if strings.Contains(got, forbidden) {
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
}
}
}
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
},
}
reg.Register(stub)
// Unknown v3 keys are forwarded unchanged; the server remains the source of
// truth for whether a field-update property is supported.
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
t.Fatalf("expected successful update, got: %s", got)
}
}
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
shortcut common.Shortcut
runtime *common.RuntimeContext
}{
{
name: "create",
shortcut: BaseFieldCreate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
{
name: "update",
shortcut: BaseFieldUpdate,
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
}
})
}
}
@@ -1091,8 +1303,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
t.Fatalf("stdout=%s", got)
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
t.Run("create generated field recommends readback", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})
@@ -1139,11 +1375,58 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
if len(fields) != 2 {
t.Fatalf("fields len=%d output=%#v", len(fields), data)
}
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
}
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
}
})
t.Run("create array with generated field recommends readback", func(t *testing.T) {
oldDelay := fieldCreateBatchDelay
fieldCreateBatchDelay = 0
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"Title"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
BodyFilter: func(body []byte) bool {
return strings.Contains(string(body), `"name":"编号"`)
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
},
})
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["created"] != true || data["total"] != float64(2) {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["fields"].([]interface{}); !ok {
t.Fatalf("batch create must keep fields array: %#v", data)
}
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
}
})
t.Run("delete", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1318,6 +1601,32 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"A,B", "@Owner"},
"record_id_list": []interface{}{"rec_alias_special"},
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
"--field-names", `"A,B",@Owner`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1614,28 +1923,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
cases := []struct {
name string
args []string
wantParam string
wantParams []string
}{
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append(append([]string{}, baseArgs...), tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
}
})
}
})
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
"--field-names", "Age",
}, factory, stdout)
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
}
})
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
cases := []struct {
name string
args []string
param string
}{
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
err := runShortcut(t, BaseRecordList, args, factory, stdout)
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
})
}
})
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_search"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(searchStub)
if err := runShortcut(t, BaseRecordSearch, []string{
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("captured body=%s", body)
}
})
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
batchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
"fields": []interface{}{"Name", "Age"},
"data": []interface{}{[]interface{}{"Alice", 18}},
},
},
}
reg.Register(batchStub)
if err := runShortcut(t, BaseRecordGet, []string{
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
"--field-names", "Name", "--field-names", "Age", "--format", "json",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
t.Fatalf("request body=%s", body)
}
})
t.Run("get", func(t *testing.T) {
@@ -2014,16 +2457,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"record_id_list": []interface{}{"rec_1"},
"update": map[string]interface{}{"Status": "Done"},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
t.Fatalf("stdout=%s", got)
}
})
@@ -2035,20 +2476,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
"data": map[string]interface{}{},
},
}
reg.Register(updateStub)
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
body := string(updateStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
t.Fatalf("request body=%s", body)
}
})

View File

@@ -28,23 +28,16 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
if name == "field-names" {
cmd.Flags().StringSlice(name, nil, "")
} else {
cmd.Flags().StringArray(name, nil, "")
}
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
@@ -61,11 +54,6 @@ func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, string
_ = cmd.Flags().Set(name, value)
}
}
for name, values := range stringSliceFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -477,6 +465,40 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
}
}
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "record get", shortcut: BaseRecordGet},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup("field-id")
if primary == nil || primary.Hidden {
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
}
help := cmd.Flags().FlagUsages()
for _, aliasName := range []string{"fields", "field-names"} {
alias := cmd.Flags().Lookup(aliasName)
if alias == nil || !alias.Hidden {
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
}
if strings.Contains(help, "--"+aliasName) {
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
}
}
})
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -786,7 +808,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
"update_records maps each record ID to its field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
},
},
}
@@ -822,6 +845,10 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"Sub-record/child-record path",
"set that link field to a parent record reference array",
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
@@ -854,9 +881,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"Happy path field: update_records",
"update_records maps each record ID to its own field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"contains only optional ignored_fields",
"does not check whether record IDs exist",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
@@ -970,11 +999,17 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
if strings.Contains(help, "reformat-existing-records") {
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
`Example auto_number update: lark-cli base +field-update`,
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
"just submit the target field definition and do not add extra low-level parameters",
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
@@ -987,6 +1022,9 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
if strings.Contains(tips, "--reformat-existing-records") {
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
@@ -1109,6 +1147,10 @@ func TestBaseFieldValidate(t *testing.T) {
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula update validate err=%v", err)
}
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
t.Fatalf("auto number update validate err=%v", err)
}
}
func TestBaseTableValidate(t *testing.T) {
@@ -1230,13 +1272,89 @@ func TestBaseRecordValidate(t *testing.T) {
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
t.Fatalf("err=%v", err)
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
map[string][]string{"field-id": {"fld_name"}},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
}
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
ctx := context.Background()
fields := make([]string, 51)
for i := range fields {
fields[i] = "Field " + strconv.Itoa(i+1)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
nil,
nil,
)); err != nil {
t.Fatalf("50 projection fields should be accepted: %v", err)
}
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
body, marshalErr := json.Marshal(map[string]interface{}{
"keyword": "Alice",
"search_fields": []string{"Name"},
"select_fields": fields,
})
if marshalErr != nil {
t.Fatalf("marshal search body: %v", marshalErr)
}
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
}
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
}, nil, nil)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("recordSearchJSONBody() error = %v", err)
}
if _, exists := body["select_fields"]; exists {
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
}
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
}
}
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
ctx := context.Background()
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
}
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {

View File

@@ -5,6 +5,7 @@ package base
import (
"context"
"fmt"
"strings"
"time"
@@ -36,7 +37,10 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
dr := common.NewDryRunAPI().
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
@@ -48,7 +52,10 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
pc := newParseCtx(runtime)
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
}
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Body(body).
@@ -166,10 +173,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
fields = append(fields, data)
}
if len(fields) == 1 {
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
return nil
}
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
return nil
}
@@ -197,10 +204,101 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
return nil
}
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
// fieldCreateBatchResult attaches the same top-level readback contract to a
// multi-field create. It recommends +field-get when any submitted field is a
// computed/linked/generated (or unknown) type, so agents know when to verify
// server state without breaking the existing fields/total structure.
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
recommend := false
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
for _, body := range submitted {
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
recommend = true
reason = r
break
}
}
return attachFieldReadbackRecommendation(result, recommend, reason)
}
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
returnedType := normalizeFieldType(fieldResultType(result["field"]))
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
}
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
if returnedType != "" && submittedType != "" && returnedType != submittedType {
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
}
fieldType := returnedType
if fieldType == "" {
fieldType = submittedType
}
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
}
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
}
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
result["field_get_recommended"] = readbackRecommended
result["verification_hint"] = reason
if readbackRecommended {
result["next_step"] = "field_get"
} else {
result["next_step"] = "done"
}
return result
}
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
return fieldTypeReadbackRecommendation(fieldType, operation)
}
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
fieldType = normalizeFieldType(fieldType)
switch fieldType {
case "formula", "lookup", "auto_number", "link":
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
default:
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
}
}
func normalizeFieldType(fieldType string) string {
return strings.ToLower(strings.TrimSpace(fieldType))
}
func fieldResultType(value interface{}) string {
field, ok := value.(map[string]interface{})
if !ok {
return ""
}
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
return fieldType
}
nested, ok := field["field"].(map[string]interface{})
if !ok {
return ""
}
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
}
func executeFieldDelete(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableIDValue := baseTableID(runtime)

View File

@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
baseHighRiskYesTip,
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
"Formula and lookup updates require reading the corresponding guide first.",
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",

View File

@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
t.Fatalf("fields=%v err=%v", fields, err)
}
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("err=%v", err)
}

View File

@@ -12,18 +12,19 @@ import (
var BaseRecordBatchUpdate = common.Shortcut{
Service: "base",
Command: "+record-batch-update",
Description: "Batch update records",
Description: "Batch update records with record-specific fields",
Risk: "write",
Scopes: []string{"base:record:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
{Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
},
Tips: append([]string{
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
"Happy path field: update_records maps each record ID to its own field map.",
`Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
"The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
recordReadFormatFlag(),
},

View File

@@ -20,8 +20,9 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -44,9 +45,6 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordListFieldAlias(runtime); err != nil {
return err
}
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -61,6 +59,9 @@ var BaseRecordList = common.Shortcut{
return err
}
}
if _, err := recordProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
},
DryRun: dryRunRecordList,
@@ -72,22 +73,6 @@ var BaseRecordList = common.Shortcut{
},
}
func recordListFieldRefFlag() common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = "field ID or name to include; repeat to project only needed fields"
return flag
}
func recordListFieldNamesAliasFlag() common.Flag {
return common.Flag{
Name: "field-names",
Type: "string_slice",
Desc: "hidden alias for --field-id; accepts comma-separated field names",
Hidden: true,
}
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -102,10 +87,3 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
if runtime.Changed("field-id") && runtime.Changed("field-names") {
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
}
return nil
}

View File

@@ -5,15 +5,18 @@ package base
import (
"context"
"errors"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRecordSelectionCount = 200
const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
@@ -46,7 +49,6 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
recordIDs := runtime.StrArray("record-id")
fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
@@ -69,7 +71,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
if err != nil {
return recordSelection{}, err
}
@@ -83,7 +89,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
projectionFields, err := recordProjectionFields(runtime)
if err != nil {
return recordSelection{}, err
}
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
if err != nil {
return recordSelection{}, err
}
@@ -104,20 +114,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
})
}
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
if err != nil {
return nil, err
return nil, withValidationParam(err, projectionParam)
}
if body == nil {
return fromFlags, nil
}
rawJSONFields, ok := body["select_fields"]
if !ok {
if !ok || rawJSONFields == nil {
return fromFlags, nil
}
if len(fromFlags) > 0 {
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
}
items, ok := rawJSONFields.([]interface{})
if !ok {
@@ -128,18 +138,26 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
}
normalized, err := normalizeRecordGetSelectFields(items)
if err != nil {
return nil, err
return nil, withValidationParam(err, "--json")
}
return normalized, nil
}
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
}
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
}
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
return normalizeStringList(values, stringListNormalizeOptions{
typeError: "field selection must be a string array",
itemName: "field selection item",
duplicateName: "field id",
limitName: "field selection",
max: maxBatchGetSelectFieldCount,
max: max,
allowNil: true,
allowEmpty: true,
})
@@ -211,7 +229,11 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
params := url.Values{}
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
for _, field := range recordListFields(runtime) {
fields, err := recordProjectionFields(runtime)
if err != nil {
return common.NewDryRunAPI()
}
for _, field := range fields {
params.Add("field_id", field)
}
if viewID := runtime.Str("view-id"); viewID != "" {
@@ -375,11 +397,121 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
return err
}
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
func recordProjectionFieldFlag(desc string) common.Flag {
flag := fieldRefFlag(false)
flag.Type = "string_array"
flag.Desc = desc
return flag
}
func recordProjectionAliasFlag(name string) common.Flag {
flagType := "string_array"
if name == "field-names" {
// Preserve the original compatibility contract: --field-names uses
// pflag's CSV parser, including quoted commas, and treats @ literally.
flagType = "string_slice"
}
return runtime.StrArray("field-id")
return common.Flag{
Name: name,
Type: flagType,
Desc: "hidden alias for --field-id projection",
Hidden: true,
}
}
func recordProjectionParam(runtime *common.RuntimeContext) string {
switch {
case runtime.Changed("fields"):
return "--fields"
case runtime.Changed("field-names"):
return "--field-names"
default:
return "--field-id"
}
}
func withValidationParam(err error, param string) error {
if err == nil || param == "" {
return err
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return err
}
reason := validationErr.Error()
// The caller knows which input produced this validation error. Replace any
// params inferred from the rendered message: field values such as Cost--USD
// must not be mistaken for a --USD flag.
validationErr.Param = param
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
return err
}
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
}
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
}
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
fieldIDs := runtime.StrArray("field-id")
fieldIDsSet := runtime.Changed("field-id")
fieldsSet := runtime.Changed("fields")
fieldNamesSet := runtime.Changed("field-names")
projectionParams := make([]string, 0, 3)
if fieldIDsSet {
projectionParams = append(projectionParams, "--field-id")
}
if fieldsSet {
projectionParams = append(projectionParams, "--fields")
}
if fieldNamesSet {
projectionParams = append(projectionParams, "--field-names")
}
if len(projectionParams) > 1 {
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
for _, param := range projectionParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
WithParam(projectionParams[0]).
WithParams(invalidParams...).
WithHint("Use only --field-id for projection.")
}
if fieldsSet {
return recordProjectionAliasFields(runtime, "fields", max)
}
if fieldNamesSet {
return recordProjectionAliasFields(runtime, "field-names", max)
}
fields, err := normalizeRecordSelectFields(fieldIDs, max)
return fields, withValidationParam(err, "--field-id")
}
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
var fields []string
if flagName == "field-names" {
fields = runtime.StrSlice(flagName)
} else {
pc := newParseCtx(runtime)
values := runtime.StrArray(flagName)
fields = make([]string, 0, len(values))
for _, raw := range values {
parsed, err := parseStringListFlexible(pc, raw, flagName)
if err != nil {
return nil, withValidationParam(err, "--"+flagName)
}
fields = append(fields, parsed...)
}
}
if len(fields) == 0 {
err := baseFlagErrorf("--%s must include at least one field", flagName)
return nil, withValidationParam(err, "--"+flagName)
}
normalized, err := normalizeRecordSelectFields(fields, max)
return normalized, withValidationParam(err, "--"+flagName)
}
func executeRecordList(runtime *common.RuntimeContext) error {
@@ -392,7 +524,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
}
limit := getPaginationLimit(runtime)
params := map[string]interface{}{"offset": offset, "limit": limit}
fields := recordListFields(runtime)
fields, err := recordProjectionFields(runtime)
if err != nil {
return err
}
if len(fields) > 0 {
params["field_id"] = fields
}

View File

@@ -9,6 +9,7 @@ import (
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -174,7 +175,10 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
if len(searchFields) > 0 {
body["search_fields"] = searchFields
}
selectFields := recordListFields(runtime)
selectFields, err := recordSearchProjectionFields(runtime)
if err != nil {
return nil, err
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
@@ -203,6 +207,19 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
}
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
if rawSelectFields, ok := body["select_fields"]; ok {
if rawSelectFields == nil {
delete(body, "select_fields")
} else {
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
if err != nil {
return withValidationParam(err, "--json")
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
}
}
if rawSort, ok := body["sort"]; ok {
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
body["sort"] = sortConfig
@@ -219,8 +236,20 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
allParams := append([]string{"--json"}, exclusiveParams...)
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
for _, param := range allParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--json is mutually exclusive with %s",
strings.Join(exclusiveParams, " and "),
).
WithParam("--json").
WithParams(invalidParams...).
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
}
_, err := recordSearchJSONBody(runtime)
return err
@@ -242,17 +271,31 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
return err
}
}
if _, err := recordSearchProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
}
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
len(runtime.StrArray("search-field")) > 0 ||
len(recordListFields(runtime)) > 0 ||
runtime.Str("view-id") != "" ||
runtime.Changed("offset") ||
runtime.Changed("limit") ||
runtime.Changed("page-size")
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
names := []string{
"keyword",
"search-field",
"field-id",
"fields",
"field-names",
"view-id",
"offset",
"limit",
"page-size",
}
params := make([]string, 0, len(names))
for _, name := range names {
if runtime.Changed(name) {
params = append(params, "--"+name)
}
}
return params
}
func formatRecordQueryPriorityTip() string {

View File

@@ -23,7 +23,9 @@ var BaseRecordSearch = common.Shortcut{
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
recordListFieldRefFlag(),
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
recordProjectionAliasFlag("fields"),
recordProjectionAliasFlag("field-names"),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),

View File

@@ -26,6 +26,7 @@ var BaseRecordUpsert = common.Shortcut{
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Sub-record/child-record path: when a one-way/two-way link field represents hierarchy, create a normal record and set that link field to a parent record reference array, e.g. {\"Parent Link\":[{\"id\":\"rec_xxx\"}]}; do not look for parent_record_id or a separate child-record API.",
"Use the record-upsert guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -67,6 +67,25 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
// selfAttendeeId resolves the open_id of the identity running the command so it
// can be auto-added to the attendee list, mirroring how a human user is joined
// to their own events. For a user it comes from config; for a bot it is fetched
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
// is still created with the explicitly requested attendees.
func selfAttendeeId(runtime *common.RuntimeContext) string {
if !runtime.IsBot() {
return runtime.UserOpenId()
}
info, err := runtime.BotInfo()
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
err)
return ""
}
return info.OpenID
}
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
@@ -176,7 +195,9 @@ var CalendarCreate = common.Shortcut{
eventData := buildEventData(runtime, startTs, endTs)
attendeesStr := runtime.Str("attendee-ids")
if attendeesStr != "" {
// Note: dry-run doesn't network resolve the current user's open_id.
// Note: dry-run doesn't network resolve the running identity's own
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
// self attendee is not shown here.
attendees, err := parseAttendees(attendeesStr, "")
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -228,11 +249,8 @@ var CalendarCreate = common.Shortcut{
// Add attendees if specified
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
currentUserId := ""
if !runtime.IsBot() {
currentUserId = runtime.UserOpenId()
}
attendees, err := parseAttendees(attendeesStr, currentUserId)
selfId := selfAttendeeId(runtime)
attendees, err := parseAttendees(attendeesStr, selfId)
if err != nil {
return withParam(err, "--attendee-ids")
}

View File

@@ -251,6 +251,136 @@ func TestCreate_WithAttendees_Success(t *testing.T) {
}
}
func TestCreate_WithAttendees_AsBot_AddsBotSelf(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_botself",
"app_name": "Test Bot",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_bot",
"summary": "Bot Sync",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
},
},
},
},
})
attendeesStub := &httpmock.Stub{
Method: "POST",
URL: "/events/evt_bot/attendees",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(attendeesStub)
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bot Sync",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "ou_user1",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attendeesStub.CapturedBody == nil {
t.Fatal("attendees API was not called")
}
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
t.Fatalf("expected bot open_id ou_botself in attendees request, got: %s", attendeesStub.CapturedBody)
}
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
}
}
func TestCreate_WithAttendees_AsBot_BotInfoFails_ProceedsWithoutBot(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 99991663, "msg": "app ticket invalid",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_nobot",
"summary": "Bot Sync",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
},
},
},
},
})
attendeesStub := &httpmock.Stub{
Method: "POST",
URL: "/events/evt_nobot/attendees",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(attendeesStub)
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Bot Sync",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "ou_user1",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attendeesStub.CapturedBody == nil {
t.Fatal("attendees API was not called")
}
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
}
if bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
t.Fatalf("bot open_id should be absent when /bot/v3/info fails, got: %s", attendeesStub.CapturedBody)
}
}
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
wantParam: "--pattern",
},
{
name: "XML str_replace rejects multiline pattern",
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
wantParam: "--pattern",
},
{
name: "block_delete without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
wantParam: "--block-id",
},
{
name: "block_delete rejects empty ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
wantParam: "--block-id",
},
{
name: "block_delete rejects duplicate ID",
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
wantParam: "--block-id",
},
{
name: "block_insert_after without block id",
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},

View File

@@ -17,6 +17,46 @@ import (
// ── V2 (OpenAPI) tests ──
func TestStripTopLevelXMLTitles(t *testing.T) {
t.Parallel()
tests := []struct {
name string
content string
want string
}{
{
name: "single title",
content: "<title>Content title</title><p>body</p>",
want: "<p>body</p>",
},
{
name: "multiple titles",
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
want: "<p>body</p>",
},
{
name: "nested title is preserved",
content: "<callout><title>Nested</title></callout><p>body</p>",
want: "<callout><title>Nested</title></callout><p>body</p>",
},
{
name: "malformed XML is preserved",
content: "<title>Content title</title><p>A & B</p>",
want: "<title>Content title</title><p>A & B</p>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
}
})
}
}
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()

View File

@@ -7,6 +7,8 @@ import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"strings"
"github.com/larksuite/cli/errs"
@@ -16,7 +18,7 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
if title == "" {
return content
}
if runtime.Str("doc-format") == "xml" {
content = stripTopLevelXMLTitles(content)
}
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
if content == "" {
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
return titleTag + "\n" + content
}
type docContentRange struct {
start int64
end int64
}
// stripTopLevelXMLTitles preserves the established --title-wins contract while
// avoiding duplicate-title warnings from XML content. If the fragment is not
// well-formed XML, it is left untouched for the service to diagnose.
func stripTopLevelXMLTitles(content string) string {
const wrapperStart = "<root>"
wrapped := wrapperStart + content + "</root>"
decoder := xml.NewDecoder(strings.NewReader(wrapped))
wrapperLen := int64(len(wrapperStart))
depth := 0
activeStart := int64(-1)
ranges := make([]docContentRange, 0, 1)
for {
tokenStart := decoder.InputOffset()
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return content
}
switch value := token.(type) {
case xml.StartElement:
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
activeStart = tokenStart - wrapperLen
}
depth++
case xml.EndElement:
depth--
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
activeStart = -1
}
}
}
if len(ranges) == 0 {
return content
}
var result strings.Builder
cursor := int64(0)
for _, item := range ranges {
result.WriteString(content[int(cursor):int(item.start)])
cursor = item.end
}
result.WriteString(content[int(cursor):])
return strings.TrimSpace(result.String())
}
func escapeDocTitleText(title string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(title))

View File

@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if pattern == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
}
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
}
case "block_delete":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
}
if err := validateBlockDeleteIDs(blockID); err != nil {
return err
}
case "block_insert_after":
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return nil
}
func validateBlockDeleteIDs(raw string) error {
seen := make(map[string]struct{})
for _, part := range strings.Split(raw, ",") {
blockID := strings.TrimSpace(part)
if blockID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
}
if _, ok := seen[blockID]; ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
}
seen[blockID] = struct{}{}
}
return nil
}
func normalizeBlockDeleteIDs(raw string) string {
parts := strings.Split(raw, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return strings.Join(parts, ",")
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
body["pattern"] = v
}
if blockID != "" {
if cmd == "block_delete" {
blockID = normalizeBlockDeleteIDs(blockID)
}
body["block_id"] = blockID
}
if v := runtime.Str("src-block-ids"); v != "" {

View File

@@ -651,7 +651,6 @@ func TestShortcuts(t *testing.T) {
want := []string{
"+chat-create",
"+chat-list",
"+chat-members-add",
"+chat-members-list",
"+chat-messages-list",
"+chat-search",

View File

@@ -1,305 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const imChatMembersAddPathFmt = "/open-apis/im/v1/chats/%s/members"
const (
chatMembersAddMaxUsers = 50
chatMembersAddMaxBots = 5
)
// collectMemberAddIDs reads a string_slice flag, trims/dedupes its values,
// and enforces the ID prefix and the per-call count limit dictated by
// chat.members.create (50 users / 5 bots per request).
func collectMemberAddIDs(runtime *common.RuntimeContext, flag, prefix string, max int) ([]string, error) {
raw := runtime.StrSlice(flag)
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, v := range raw {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if !strings.HasPrefix(v, prefix) {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --%s value %q: must start with %q", flag, v, prefix).WithParam("--" + flag)
}
if _, dup := seen[v]; dup {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
if len(out) > max {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--%s exceeds the maximum of %d (got %d)", flag, max, len(out)).WithParam("--" + flag)
}
return out, nil
}
// validateChatMembersAdd checks --chat-id format and that --users/--bots
// each satisfy their prefix and count-limit rules, and that at least one of
// them is non-empty. All checks happen locally so bad input never reaches
// the API layer.
func validateChatMembersAdd(runtime *common.RuntimeContext) error {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
if !strings.HasPrefix(chatID, "oc_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
}
users, err := collectMemberAddIDs(runtime, "users", "ou_", chatMembersAddMaxUsers)
if err != nil {
return err
}
bots, err := collectMemberAddIDs(runtime, "bots", "cli_", chatMembersAddMaxBots)
if err != nil {
return err
}
if len(users) == 0 && len(bots) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --users or --bots is required")
}
return nil
}
// chatMembersAddResult is the merged ledger across the users-call and the
// bots-call. rawCallErrors is parallel to callErrors (same append order) and
// carries the original typed error instead of its stringified form, so the
// caller can classify it (e.g. auth/permission) without re-parsing error
// text; it never enters the JSON output.
type chatMembersAddResult struct {
succeeded []string
invalid []string
notExisted []string
pendingApproval []string
callErrors []map[string]interface{}
rawCallErrors []error
}
func newChatMembersAddResult() *chatMembersAddResult {
return &chatMembersAddResult{
succeeded: []string{},
invalid: []string{},
notExisted: []string{},
pendingApproval: []string{},
callErrors: []map[string]interface{}{},
}
}
// addChatMembersBatch issues one chat.members.create call for a single
// member_id_type and folds the outcome into res. A full-call error (e.g.
// missing scope, chat-wide bot cap exceeded) is recorded as a call_errors
// entry carrying the affected id_list, rather than aborting the other call.
func addChatMembersBatch(runtime *common.RuntimeContext, chatID, memberType, memberIDType string, ids []string, res *chatMembersAddResult) {
path := fmt.Sprintf(imChatMembersAddPathFmt, validate.EncodePathSegment(chatID))
data, err := runtime.DoAPIJSONTyped(http.MethodPost, path,
larkcore.QueryParams{
"member_id_type": []string{memberIDType},
"succeed_type": []string{"1"},
},
map[string]interface{}{"id_list": ids},
)
if err != nil {
res.callErrors = append(res.callErrors, map[string]interface{}{
"member_type": memberType,
"id_list": ids,
"error": err.Error(),
})
res.rawCallErrors = append(res.rawCallErrors, err)
return
}
invalid := stringsFromAny(data["invalid_id_list"])
notExisted := stringsFromAny(data["not_existed_id_list"])
pending := stringsFromAny(data["pending_approval_id_list"])
res.invalid = append(res.invalid, invalid...)
res.notExisted = append(res.notExisted, notExisted...)
res.pendingApproval = append(res.pendingApproval, pending...)
failed := make(map[string]struct{}, len(invalid)+len(notExisted)+len(pending))
for _, id := range invalid {
failed[id] = struct{}{}
}
for _, id := range notExisted {
failed[id] = struct{}{}
}
for _, id := range pending {
failed[id] = struct{}{}
}
for _, id := range ids {
if _, isFailed := failed[id]; !isFailed {
res.succeeded = append(res.succeeded, id)
}
}
}
// stringsFromAny converts a JSON-decoded []interface{} of strings to []string,
// skipping any non-string entries defensively.
func stringsFromAny(v interface{}) []string {
arr, ok := v.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
// ImChatMembersAdd is the +chat-members-add shortcut: adds users and/or bots
// to a group chat. --users (open_id) and --bots (app_id) map to two
// independent underlying calls to POST /open-apis/im/v1/chats/{chat_id}/members
// (member_id_type differs per call — a single call cannot mix open_id and
// app_id, because chat.members.create only accepts one member_id_type per
// request). Both calls use succeed_type=1 (best effort): valid IDs are added
// even if others are invalid/nonexistent/pending approval, so a single
// resigned user does not block everyone else. Results are merged into one
// ledger so the caller doesn't have to reconcile two API responses by hand.
var ImChatMembersAdd = common.Shortcut{
Service: "im",
Command: "+chat-members-add",
Description: "Add users and/or bots to a group chat; user/bot; batches --users (open_id) and --bots (app_id) into up to 2 API calls under best-effort semantics; returns a merged succeeded/invalid/not_existed/pending_approval ledger",
Risk: "write",
// Declare the narrowest scope the API accepts so tokens carrying only
// im:chat.members:write_only are honored (same rationale as
// +chat-members-list): chat.members.create's raw meta lists
// ["im:chat", "im:chat.members:write_only"] as OR alternatives, but the
// local scope precheck (internal/auth/scope.go's MissingScopes) treats
// every entry in Scopes as required (AND semantics), so listing both here
// would wrongly reject a token that only carries the narrow scope.
Scopes: []string{"im:chat.members:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "chat-id", Required: true, Desc: "chat ID to add members to (oc_xxx)"},
{Name: "users", Type: "string_slice", Desc: "user open_ids to invite (ou_xxx); comma-separated or repeat the flag; max 50"},
{Name: "bots", Type: "string_slice", Desc: "bot app_ids to invite (cli_xxx); comma-separated or repeat the flag; max 5"},
},
Tips: []string{
"lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a,ou_b --bots cli_x",
"--users and --bots are independent; at least one is required, but providing both issues two API calls internally and merges the results into one ledger.",
"Partial failures (resigned users, nonexistent IDs, pending approval) don't block the other valid IDs from being added — check failure_count and the per-reason lists in the result.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateChatMembersAdd(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
users, _ := collectMemberAddIDs(runtime, "users", "ou_", chatMembersAddMaxUsers)
bots, _ := collectMemberAddIDs(runtime, "bots", "cli_", chatMembersAddMaxBots)
path := fmt.Sprintf(imChatMembersAddPathFmt, validate.EncodePathSegment(chatID))
dry := common.NewDryRunAPI()
if len(users) > 0 {
dry.POST(path).
Params(map[string]interface{}{"member_id_type": "open_id", "succeed_type": 1}).
Body(map[string]interface{}{"id_list": users})
}
if len(bots) > 0 {
dry.POST(path).
Params(map[string]interface{}{"member_id_type": "app_id", "succeed_type": 1}).
Body(map[string]interface{}{"id_list": bots})
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeChatMembersAdd(runtime)
},
}
// executeChatMembersAdd issues one call per non-empty member list and merges
// the results. The two calls are independent: a full-call failure on one
// (e.g. bot count over the chat-wide cap) does not prevent the other from
// running or from having its successes reported.
func executeChatMembersAdd(runtime *common.RuntimeContext) error {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
users, err := collectMemberAddIDs(runtime, "users", "ou_", chatMembersAddMaxUsers)
if err != nil {
return err
}
bots, err := collectMemberAddIDs(runtime, "bots", "cli_", chatMembersAddMaxBots)
if err != nil {
return err
}
res := newChatMembersAddResult()
attempted := 0
if len(users) > 0 {
attempted++
addChatMembersBatch(runtime, chatID, "user", "open_id", users, res)
}
if len(bots) > 0 {
attempted++
addChatMembersBatch(runtime, chatID, "bot", "app_id", bots, res)
}
// Per spec: only when BOTH attempted calls failed, and both failures
// classify as auth/permission errors, surface the typed error directly
// instead of building a ledger. A single attempted call failing this way
// still falls through to the normal ledger path below.
if attempted == 2 && len(res.rawCallErrors) == 2 && allAuthClassified(res.rawCallErrors) {
return res.rawCallErrors[0]
}
total := len(users) + len(bots)
failureCount := len(res.invalid) + len(res.notExisted) + len(res.pendingApproval)
for _, ce := range res.callErrors {
if ids, ok := ce["id_list"].([]string); ok {
failureCount += len(ids)
}
}
successCount := total - failureCount
outData := map[string]interface{}{
"chat_id": chatID,
"succeeded_id_list": res.succeeded,
"invalid_id_list": res.invalid,
"not_existed_id_list": res.notExisted,
"pending_approval_id_list": res.pendingApproval,
"call_errors": res.callErrors,
"success_count": successCount,
"failure_count": failureCount,
"total": total,
}
if failureCount > 0 {
return runtime.OutPartialFailure(outData, nil)
}
runtime.Out(outData, nil)
return nil
}
// allAuthClassified reports whether every error in errsList classifies as an
// auth/permission-category typed error (errs.CategoryAuthentication or
// errs.CategoryAuthorization). An empty slice is not "all classified" — the
// caller only calls this when len(errsList) == 2, but the explicit false
// guards against a future call site passing an empty slice by mistake.
func allAuthClassified(errsList []error) bool {
if len(errsList) == 0 {
return false
}
for _, e := range errsList {
cat := errs.CategoryOf(e)
if cat != errs.CategoryAuthentication && cat != errs.CategoryAuthorization {
return false
}
}
return true
}

View File

@@ -1,357 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestCollectMemberAddIDs(t *testing.T) {
cases := []struct {
name string
flag string
prefix string
max int
raw []string
want []string
wantErr bool
}{
{"empty is ok", "users", "ou_", chatMembersAddMaxUsers, nil, []string{}, false},
{"dedupes", "users", "ou_", chatMembersAddMaxUsers, []string{"ou_a", "ou_a", "ou_b"}, []string{"ou_a", "ou_b"}, false},
{"trims whitespace", "users", "ou_", chatMembersAddMaxUsers, []string{" ou_a ", ""}, []string{"ou_a"}, false},
{"wrong prefix", "users", "ou_", chatMembersAddMaxUsers, []string{"cli_a"}, nil, true},
{"over limit", "bots", "cli_", 2, []string{"cli_a", "cli_b", "cli_c"}, nil, true},
}
for _, c := range cases {
got, err := collectMemberAddIDs(newBareTestRuntime(t, map[string][]string{c.flag: c.raw}), c.flag, c.prefix, c.max)
if c.wantErr {
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", c.name, err, err)
}
continue
}
if err != nil {
t.Fatalf("%s: unexpected error %v", c.name, err)
}
if !equalStringSlices(got, c.want) {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestValidateChatMembersAdd(t *testing.T) {
cases := []struct {
name string
chatID string
users []string
bots []string
wantErr bool
wantParam string
}{
{"valid users only", "oc_x", []string{"ou_a"}, nil, false, ""},
{"valid bots only", "oc_x", nil, []string{"cli_a"}, false, ""},
{"valid both", "oc_x", []string{"ou_a"}, []string{"cli_a"}, false, ""},
{"missing chat-id", "", []string{"ou_a"}, nil, true, "--chat-id"},
{"bad chat-id prefix", "abc", []string{"ou_a"}, nil, true, "--chat-id"},
{"neither users nor bots", "oc_x", nil, nil, true, ""},
}
for _, c := range cases {
rt := newChatMembersAddTestRuntime(t, nil, map[string]string{"chat-id": c.chatID}, map[string][]string{"users": c.users, "bots": c.bots})
err := validateChatMembersAdd(rt)
if c.wantErr {
if err == nil {
t.Errorf("%s: want error, got nil", c.name)
continue
}
if c.wantParam != "" {
assertValidationError(t, c.name, err, c.wantParam)
}
continue
}
if err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
// newBareTestRuntime builds a runtime with only string_slice flags registered
// (no HTTP transport needed) for pure-function tests like collectMemberAddIDs.
func newBareTestRuntime(t *testing.T, slices map[string][]string) *common.RuntimeContext {
t.Helper()
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
cmd := &cobra.Command{Use: "test"}
for flag := range slices {
cmd.Flags().StringSlice(flag, nil, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
for flag, vals := range slices {
for _, v := range vals {
if err := cmd.Flags().Set(flag, v); err != nil {
t.Fatalf("set %s: %v", flag, err)
}
}
}
rt.Cmd = cmd
return rt
}
// newChatMembersAddTestRuntime wires --chat-id/--users/--bots for the full
// Validate/DryRun/Execute surface.
func newChatMembersAddTestRuntime(t *testing.T, rtRoundTripper http.RoundTripper, str map[string]string, slices map[string][]string) *common.RuntimeContext {
t.Helper()
if rtRoundTripper == nil {
rtRoundTripper = shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
})
}
runtime := newUserShortcutRuntime(t, rtRoundTripper)
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("chat-id", "", "")
cmd.Flags().StringSlice("users", nil, "")
cmd.Flags().StringSlice("bots", nil, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
for k, v := range str {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for flag, vals := range slices {
for _, v := range vals {
if err := cmd.Flags().Set(flag, v); err != nil {
t.Fatalf("set %s: %v", flag, err)
}
}
}
runtime.Cmd = cmd
return runtime
}
func TestAddChatMembersBatch_AllSucceed(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}))
res := newChatMembersAddResult()
addChatMembersBatch(rt, "oc_x", "user", "open_id", []string{"ou_a", "ou_b"}, res)
if !equalStringSlices(res.succeeded, []string{"ou_a", "ou_b"}) {
t.Errorf("succeeded = %v, want [ou_a ou_b]", res.succeeded)
}
if len(res.invalid) != 0 || len(res.notExisted) != 0 || len(res.pendingApproval) != 0 || len(res.callErrors) != 0 {
t.Errorf("expected no failures, got %+v", res)
}
}
func TestAddChatMembersBatch_PartialFailure(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"invalid_id_list": []interface{}{"ou_c"},
"not_existed_id_list": []interface{}{"ou_d"},
"pending_approval_id_list": []interface{}{"ou_e"},
},
}), nil
}))
res := newChatMembersAddResult()
addChatMembersBatch(rt, "oc_x", "user", "open_id", []string{"ou_a", "ou_b", "ou_c", "ou_d", "ou_e"}, res)
if !equalStringSlices(res.succeeded, []string{"ou_a", "ou_b"}) {
t.Errorf("succeeded = %v, want [ou_a ou_b]", res.succeeded)
}
if !equalStringSlices(res.invalid, []string{"ou_c"}) {
t.Errorf("invalid = %v, want [ou_c]", res.invalid)
}
if !equalStringSlices(res.notExisted, []string{"ou_d"}) {
t.Errorf("notExisted = %v, want [ou_d]", res.notExisted)
}
if !equalStringSlices(res.pendingApproval, []string{"ou_e"}) {
t.Errorf("pendingApproval = %v, want [ou_e]", res.pendingApproval)
}
}
func TestAddChatMembersBatch_CallLevelFailure(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(400, map[string]interface{}{"code": 123, "msg": "bot count exceeds chat limit"}), nil
}))
res := newChatMembersAddResult()
addChatMembersBatch(rt, "oc_x", "bot", "app_id", []string{"cli_y"}, res)
if len(res.succeeded) != 0 {
t.Errorf("succeeded = %v, want empty (call failed)", res.succeeded)
}
if len(res.callErrors) != 1 {
t.Fatalf("callErrors = %v, want 1 entry", res.callErrors)
}
ce := res.callErrors[0]
if ce["member_type"] != "bot" {
t.Errorf("call_errors[0].member_type = %v, want bot", ce["member_type"])
}
ids, _ := ce["id_list"].([]string)
if !equalStringSlices(ids, []string{"cli_y"}) {
t.Errorf("call_errors[0].id_list = %v, want [cli_y]", ids)
}
}
func TestImChatMembersAddExecute_AllSucceed(t *testing.T) {
var gotPaths []string
rt := newChatMembersAddTestRuntime(t,
shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPaths = append(gotPaths, req.URL.Path+"?"+req.URL.RawQuery)
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}),
map[string]string{"chat-id": "oc_x"},
map[string][]string{"users": {"ou_a"}, "bots": {"cli_x"}},
)
err := ImChatMembersAdd.Execute(context.Background(), rt)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if len(gotPaths) != 2 {
t.Fatalf("want 2 API calls (users + bots), got %d: %v", len(gotPaths), gotPaths)
}
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
if !strings.Contains(out, `"ok": true`) {
t.Errorf("output = %s, want ok:true", out)
}
if !strings.Contains(out, `"success_count": 2`) {
t.Errorf("output = %s, want success_count 2", out)
}
}
func TestImChatMembersAddExecute_PartialFailure(t *testing.T) {
rt := newChatMembersAddTestRuntime(t,
shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Query().Get("member_id_type") == "open_id" {
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"invalid_id_list": []interface{}{"ou_c"}},
}), nil
}
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": map[string]interface{}{}}), nil
}),
map[string]string{"chat-id": "oc_x"},
map[string][]string{"users": {"ou_a", "ou_c"}, "bots": {"cli_x"}},
)
err := ImChatMembersAdd.Execute(context.Background(), rt)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("Execute() error = %T %v, want partial failure", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Fatalf("partial failure exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
if !strings.Contains(out, `"ok": false`) {
t.Errorf("output = %s, want ok:false", out)
}
if !strings.Contains(out, `"failure_count": 1`) {
t.Errorf("output = %s, want failure_count 1", out)
}
if !strings.Contains(out, `"success_count": 2`) {
t.Errorf("output = %s, want success_count 2", out)
}
}
func TestImChatMembersAddDryRun_TwoCallsWhenBothFlags(t *testing.T) {
rt := newChatMembersAddTestRuntime(t, nil,
map[string]string{"chat-id": "oc_x"},
map[string][]string{"users": {"ou_a"}, "bots": {"cli_x"}},
)
dry := ImChatMembersAdd.DryRun(context.Background(), rt)
b, err := json.Marshal(dry)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if strings.Count(string(b), `"method":"POST"`) != 2 {
t.Errorf("dry-run json = %s, want 2 POST calls", b)
}
}
// TestImChatMembersAddExecute_BothCallsAuthFailure_ReturnsTypedError covers
// the spec rule: when BOTH the users-call and the bots-call fail at the
// auth/permission classification layer, Execute must return that typed error
// directly (no ledger, no OutPartialFailure) instead of folding it into
// call_errors. Lark error code 99991672 ("app_missing_scope") is the same
// fixture code internal/errclass/classify_test.go uses to assert
// CategoryAuthorization — using it here means DoAPIJSONTyped's real
// classifier produces the *errs.PermissionError, not a hand-built stand-in.
func TestImChatMembersAddExecute_BothCallsAuthFailure_ReturnsTypedError(t *testing.T) {
rt := newChatMembersAddTestRuntime(t,
shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 99991672, "msg": "app_missing_scope"}), nil
}),
map[string]string{"chat-id": "oc_x"},
map[string][]string{"users": {"ou_a"}, "bots": {"cli_x"}},
)
err := ImChatMembersAdd.Execute(context.Background(), rt)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("Execute() error = %T %v, want *errs.PermissionError (both calls failed at auth layer)", err, err)
}
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
if out != "" {
t.Errorf("Execute() must not write a ledger when returning the typed error directly, got stdout = %s", out)
}
}
// TestImChatMembersAddExecute_SingleCallAuthFailure_StillBuildsLedger covers
// the companion rule: when only ONE call was attempted (only --users given)
// and it fails at the auth layer, spec does not apply the "both failed"
// short-circuit — it still builds a ledger (call_errors + OutPartialFailure).
func TestImChatMembersAddExecute_SingleCallAuthFailure_StillBuildsLedger(t *testing.T) {
rt := newChatMembersAddTestRuntime(t,
shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 99991672, "msg": "app_missing_scope"}), nil
}),
map[string]string{"chat-id": "oc_x"},
map[string][]string{"users": {"ou_a"}},
)
err := ImChatMembersAdd.Execute(context.Background(), rt)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("Execute() error = %T %v, want partial failure (single call, not the both-failed short-circuit)", err, err)
}
out := rt.Factory.IOStreams.Out.(interface{ String() string }).String()
if !strings.Contains(out, `"failure_count": 1`) {
t.Errorf("output = %s, want failure_count 1 (call_errors ledger entry)", out)
}
}

View File

@@ -10,7 +10,6 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
ImChatCreate,
ImChatList,
ImChatMembersAdd,
ImChatMembersList,
ImChatMessageList,
ImChatSearch,

View File

@@ -24,9 +24,12 @@ type batchCreateKR struct {
// batchCreateObjective represents an objective in the batch create input.
type batchCreateObjective struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
KRs []batchCreateKR `json:"krs,omitempty"`
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
Notes string `json:"notes,omitempty"`
NotesMention []string `json:"notes_mention,omitempty"`
CategoryID string `json:"category_id,omitempty"`
KRs []batchCreateKR `json:"krs,omitempty"`
}
// createdObjective tracks a created objective and its KR IDs for output.
@@ -49,6 +52,25 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
if strings.TrimSpace(obj.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
}
if obj.Notes != "" && strings.TrimSpace(obj.Notes) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes cannot be blank when provided", i).WithParam("--input")
}
if obj.Notes == "" && len(obj.NotesMention) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes is required when notes_mention is provided", i).WithParam("--input")
}
for j, mention := range obj.NotesMention {
if strings.TrimSpace(mention) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes_mention[%d] cannot be empty", i, j).WithParam("--input")
}
}
if obj.CategoryID != "" {
if strings.TrimSpace(obj.CategoryID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id cannot be blank when provided", i).WithParam("--input")
}
if id, err := strconv.ParseInt(obj.CategoryID, 10, 64); err != nil || id <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id must be a positive int64", i).WithParam("--input")
}
}
for j, kr := range obj.KRs {
if strings.TrimSpace(kr.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
@@ -59,11 +81,24 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
func effectiveBatchObjectiveCategoryID(defaultCategoryID string, obj batchCreateObjective) string {
if obj.CategoryID != "" {
return obj.CategoryID
}
return defaultCategoryID
}
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType, defaultCategoryID string, obj batchCreateObjective) (string, error) {
content := BuildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
if obj.Notes != "" {
body["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
}
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
body["category_id"] = categoryID
}
queryParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
@@ -156,6 +191,7 @@ var OKRBatchCreate = common.Shortcut{
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
{Name: "category-id", Desc: "default objective category ID for objectives that do not set category_id"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -171,6 +207,15 @@ var OKRBatchCreate = common.Shortcut{
if _, err := parseBatchCreateInput(input); err != nil {
return err
}
categoryID := runtime.Str("category-id")
if categoryID != "" {
if err := common.RejectDangerousCharsTyped("--category-id", categoryID); err != nil {
return err
}
if id, err := strconv.ParseInt(categoryID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
}
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
@@ -182,6 +227,7 @@ var OKRBatchCreate = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
defaultCategoryID := runtime.Str("category-id")
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
apis := common.NewDryRunAPI()
@@ -192,6 +238,12 @@ var OKRBatchCreate = common.Shortcut{
objBody := map[string]interface{}{
"content": objContent,
}
if obj.Notes != "" {
objBody["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
}
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
objBody["category_id"] = categoryID
}
objParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
@@ -227,6 +279,7 @@ var OKRBatchCreate = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
defaultCategoryID := runtime.Str("category-id")
objectives, err := parseBatchCreateInput(runtime.Str("input"))
if err != nil {
return err
@@ -241,7 +294,7 @@ var OKRBatchCreate = common.Shortcut{
}
// Create objective
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, defaultCategoryID, obj)
if err != nil {
if len(created) == 0 {
return err

View File

@@ -6,6 +6,8 @@ package okr
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"testing"
@@ -14,6 +16,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
"github.com/tidwall/gjson"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
@@ -43,6 +46,15 @@ const validBatchCreateInput = `[
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
const validBatchCreateInputWithNotes = `[
{"text":"Objective 1","notes":"Objective notes","notes_mention":["ou_note"],"krs":[{"text":"KR 1.1"}]}
]`
const validBatchCreateInputWithCategory = `[
{"text":"Objective 1","category_id":"222","krs":[{"text":"KR 1.1"}]},
{"text":"Objective 2","krs":[]}
]`
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
@@ -197,6 +209,46 @@ func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
}
}
func TestBatchCreateValidate_EmptyObjectiveNotesMention(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","notes":"Notes","notes_mention":[" "]}]`,
})
if err == nil {
t.Fatal("expected error for empty objective notes mention")
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--input" {
t.Fatalf("expected param --input, got: %v", err)
}
if !strings.Contains(err.Error(), "objective[0].notes_mention[0]") {
t.Fatalf("expected error to mention objective[0].notes_mention[0], got: %v", err)
}
}
func TestBatchCreateValidate_NotesMentionRequiresNotes(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","notes_mention":["ou_note"]}]`,
})
if err == nil {
t.Fatal("expected error for notes_mention without notes")
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--input" {
t.Fatalf("expected param --input, got: %v", err)
}
if !strings.Contains(err.Error(), "objective[0].notes is required when notes_mention is provided") {
t.Fatalf("expected error to mention missing notes, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
@@ -323,6 +375,49 @@ func TestBatchCreateDryRun(t *testing.T) {
}
}
func TestBatchCreateDryRun_WithObjectiveNotes(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInputWithNotes,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "Objective notes") {
t.Fatalf("dry-run output should contain objective notes, got: %s", output)
}
if !strings.Contains(output, "ou_note") {
t.Fatalf("dry-run output should contain objective notes mention, got: %s", output)
}
}
func TestBatchCreateDryRun_WithCategoryID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--category-id", "111",
"--input", validBatchCreateInputWithCategory,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "222" {
t.Fatalf("first objective category_id = %q, want per-objective override 222; output: %s", got, output)
}
if got := gjson.Get(output, "data.api.2.body.category_id").String(); got != "111" {
t.Fatalf("second objective category_id = %q, want default 111; output: %s", got, output)
}
}
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
@@ -380,6 +475,94 @@ func TestBatchCreateExecute_Success(t *testing.T) {
}
}
func TestBatchCreateExecute_ObjectiveWithNotes(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
var objectiveBody []byte
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
OnMatch: func(req *http.Request) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("read objective request body: %v", err)
}
objectiveBody = body
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInputWithNotes,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").Exists() {
t.Fatalf("objective request body missing notes: %s", string(objectiveBody))
}
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "Objective notes" {
t.Fatalf("notes text = %q, want Objective notes; body: %s", got, string(objectiveBody))
}
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
t.Fatalf("notes mention = %q, want ou_note; body: %s", got, string(objectiveBody))
}
}
func TestBatchCreateExecute_ObjectiveWithCategoryID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
var objectiveBody []byte
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
OnMatch: func(req *http.Request) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("read objective request body: %v", err)
}
objectiveBody = body
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--category-id", "7249339036661170180",
"--input", `[{"text":"Obj 1"}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := gjson.GetBytes(objectiveBody, "category_id").String(); got != "7249339036661170180" {
t.Fatalf("category_id = %q, want 7249339036661170180; body: %s", got, string(objectiveBody))
}
}
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))

394
shortcuts/okr/okr_create.go Normal file
View File

@@ -0,0 +1,394 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// createParams holds the parsed parameters for single-object create operations.
type createParams struct {
Level string
CycleID string
ObjectiveID string
Style string
Content *ContentBlock
Notes *ContentBlock
CategoryID string
UserIDType string
}
type createContentMultipleJSONValuesError struct{}
func (createContentMultipleJSONValuesError) Error() string {
return "multiple JSON values"
}
var errCreateContentMultipleJSONValues createContentMultipleJSONValuesError
type okrCreateRequestBody struct {
Content *ContentBlock `json:"content"`
Notes *ContentBlock `json:"notes,omitempty"`
CategoryID string `json:"category_id,omitempty"`
}
type okrCreateObjectiveQuery struct {
CycleID string
UserIDType string
}
type okrCreateKeyResultQuery struct {
ObjectiveID string
UserIDType string
}
type okrCreateObjectiveResponse struct {
ObjectiveID string
}
type okrCreateKeyResultResponse struct {
KeyResultID string
}
type okrCreateObjectiveOutput struct {
Level string `json:"level"`
ObjectiveID string `json:"objective_id"`
}
type okrCreateKeyResultOutput struct {
Level string `json:"level"`
ObjectiveID string `json:"objective_id"`
KeyResultID string `json:"key_result_id"`
}
func decodeCreateContentStrict(inputStr string, target interface{}, param, message string) error {
dec := json.NewDecoder(bytes.NewReader([]byte(inputStr)))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
WithParam(param).
WithCause(err)
}
var trailing interface{}
if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
err = errCreateContentMultipleJSONValues
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
WithParam(param).
WithCause(err)
}
return nil
}
func parseCreateContentValue(inputStr, param, style string) (*ContentBlock, error) {
if style == "simple" {
var sp SemiPlainContent
if err := decodeCreateContentStrict(inputStr, &sp, param, fmt.Sprintf("%s must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %%s", param)); err != nil {
return nil, err
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s text is required and cannot be empty", param).WithParam(param)
}
for i, mention := range sp.Mention {
if strings.TrimSpace(mention) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s mention[%d] cannot be empty", param, i).WithParam(param)
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s docs and images are not supported in simple style input; use richtext style or remove these fields", param).WithParam(param)
}
return sp.ToContentBlock(), nil
}
var cb ContentBlock
if err := decodeCreateContentStrict(inputStr, &cb, param, fmt.Sprintf("%s must be valid ContentBlock JSON: %%s", param)); err != nil {
return nil, err
}
if len(cb.Blocks) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must contain at least one block", param).WithParam(param)
}
hasNonEmptyParagraph := false
for _, block := range cb.Blocks {
if block.Paragraph != nil && len(block.Paragraph.Elements) > 0 {
hasNonEmptyParagraph = true
break
}
if block.Gallery != nil && len(block.Gallery.Images) > 0 {
hasNonEmptyParagraph = true
break
}
}
if !hasNonEmptyParagraph {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", param).WithParam(param)
}
return &cb, nil
}
func projectCreateRequestBody(body okrCreateRequestBody) map[string]interface{} {
result := map[string]interface{}{
"content": body.Content,
}
if body.Notes != nil {
result["notes"] = body.Notes
}
if body.CategoryID != "" {
result["category_id"] = body.CategoryID
}
return result
}
func projectCreateObjectiveQuery(query okrCreateObjectiveQuery) map[string]interface{} {
return map[string]interface{}{
"cycle_id": query.CycleID,
"user_id_type": query.UserIDType,
}
}
func projectCreateKeyResultQuery(query okrCreateKeyResultQuery) map[string]interface{} {
return map[string]interface{}{
"objective_id": query.ObjectiveID,
"user_id_type": query.UserIDType,
}
}
func projectCreateObjectiveResponse(data map[string]interface{}) (*okrCreateObjectiveResponse, error) {
objectiveID, ok := data["objective_id"].(string)
if !ok || objectiveID == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
}
return &okrCreateObjectiveResponse{ObjectiveID: objectiveID}, nil
}
func projectCreateKeyResultResponse(data map[string]interface{}) (*okrCreateKeyResultResponse, error) {
keyResultID, ok := data["key_result_id"].(string)
if !ok || keyResultID == "" {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
}
return &okrCreateKeyResultResponse{KeyResultID: keyResultID}, nil
}
// parseCreateParams parses and validates flags from runtime into request-ready parameters.
func parseCreateParams(runtime *common.RuntimeContext) (*createParams, error) {
p := &createParams{
Level: runtime.Str("level"),
CycleID: runtime.Str("cycle-id"),
ObjectiveID: runtime.Str("objective-id"),
Style: runtime.Str("style"),
CategoryID: runtime.Str("category-id"),
UserIDType: runtime.Str("user-id-type"),
}
contentStr := runtime.Str("content")
if contentStr == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
}
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
return nil, err
}
content, err := parseCreateContentValue(contentStr, "--content", p.Style)
if err != nil {
return nil, err
}
p.Content = content
if notesStr := runtime.Str("notes"); notesStr != "" {
if p.Level != "objective" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported when --level=objective").WithParam("--notes")
}
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
return nil, err
}
notes, err := parseCreateContentValue(notesStr, "--notes", p.Style)
if err != nil {
return nil, err
}
p.Notes = notes
}
if p.CategoryID != "" {
if p.Level != "objective" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id is only supported when --level=objective").WithParam("--category-id")
}
if err := common.RejectDangerousCharsTyped("--category-id", p.CategoryID); err != nil {
return nil, err
}
if id, err := strconv.ParseInt(p.CategoryID, 10, 64); err != nil || id <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
}
}
return p, nil
}
// OKRCreate creates a single objective or key result.
var OKRCreate = common.Shortcut{
Service: "okr",
Command: "+create",
Description: "Create a single OKR objective or key result",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "create level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
{Name: "cycle-id", Desc: "OKR cycle ID (required for level=objective)"},
{Name: "objective-id", Desc: "objective ID (required for level=key-result)"},
{Name: "style", Default: "simple", Desc: "input style for content: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "notes", Desc: "objective notes: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "category-id", Desc: "objective category ID; use only when classification is requested or the tenant requires categories"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
switch level {
case "objective":
if runtime.Str("objective-id") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id cannot be used when --level=objective").WithParam("--objective-id")
}
cycleID := runtime.Str("cycle-id")
if cycleID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required when --level=objective").WithParam("--cycle-id")
}
if err := common.RejectDangerousCharsTyped("--cycle-id", cycleID); err != nil {
return err
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
case "key-result":
if runtime.Str("cycle-id") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id cannot be used when --level=key-result").WithParam("--cycle-id")
}
objectiveID := runtime.Str("objective-id")
if objectiveID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if err := common.RejectDangerousCharsTyped("--objective-id", objectiveID); err != nil {
return err
}
if id, err := strconv.ParseInt(objectiveID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
_, err := parseCreateParams(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
p, err := parseCreateParams(runtime)
if err != nil {
return common.NewDryRunAPI().
POST("").
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
}
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
if p.Level == "objective" {
params := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
CycleID: p.CycleID,
UserIDType: p.UserIDType,
})
return common.NewDryRunAPI().
POST("/open-apis/okr/v2/cycles/:cycle_id/objectives").
Set("cycle_id", p.CycleID).
Params(params).
Body(body).
Desc("Create OKR objective")
}
params := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
ObjectiveID: p.ObjectiveID,
UserIDType: p.UserIDType,
})
return common.NewDryRunAPI().
POST("/open-apis/okr/v2/objectives/:objective_id/key_results").
Set("objective_id", p.ObjectiveID).
Params(params).
Body(body).
Desc("Create OKR key result")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
p, err := parseCreateParams(runtime)
if err != nil {
return err
}
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
if p.Level == "objective" {
queryParams := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
CycleID: p.CycleID,
UserIDType: p.UserIDType,
})
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", p.CycleID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to create objective")
}
resp, err := projectCreateObjectiveResponse(data)
if err != nil {
return err
}
result := okrCreateObjectiveOutput{
Level: p.Level,
ObjectiveID: resp.ObjectiveID,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created OKR objective [%s]\n", resp.ObjectiveID)
})
return nil
}
queryParams := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
ObjectiveID: p.ObjectiveID,
UserIDType: p.UserIDType,
})
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", p.ObjectiveID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to create key result")
}
resp, err := projectCreateKeyResultResponse(data)
if err != nil {
return err
}
result := okrCreateKeyResultOutput{
Level: p.Level,
ObjectiveID: p.ObjectiveID,
KeyResultID: resp.KeyResultID,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created OKR key-result [%s] under objective [%s]\n", resp.KeyResultID, p.ObjectiveID)
})
return nil
},
}

View File

@@ -0,0 +1,707 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
"github.com/tidwall/gjson"
)
func createTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-create",
AppSecret: patchTestValue(),
Brand: core.BrandFeishu,
}
}
func runCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func runCreateShortcutWithStdin(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, stdin string, args []string) error {
t.Helper()
f.IOStreams.In = strings.NewReader(stdin)
return runCreateShortcut(t, f, stdout, args)
}
const (
validCreateSimpleJSON = `{"text":"test objective","mention":["ou_123"]}`
validCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
emptyCreateRichTextJSON = `{"blocks":[]}`
blankCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[]}}]}`
validCreateObjectiveArgs1 = "+create"
)
func TestCreateValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
validCreateObjectiveArgs1,
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
})
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestCreateValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "invalid",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected invalid level error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected typed invalid argument error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--level" {
t.Fatalf("expected param --level, got: %v", err)
}
}
func TestCreateValidate_MissingCycleIDForObjective(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected missing cycle-id error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected typed invalid argument error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got: %v", err)
}
}
func TestCreateValidate_InvalidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "abc",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected invalid cycle-id error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected typed invalid argument error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got: %v", err)
}
}
func TestCreateValidate_MissingObjectiveIDForKR(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected missing objective-id error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected typed invalid argument error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got: %v", err)
}
}
func TestCreateValidate_RejectObjectiveIDForObjective(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--objective-id", "456",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected objective-id rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got: %v", err)
}
}
func TestCreateValidate_RejectCycleIDForKeyResult(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected cycle-id rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got: %v", err)
}
}
func TestCreateValidate_RejectNotesForKeyResult(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--objective-id", "456",
"--content", validCreateSimpleJSON,
"--notes", `{"text":"objective only notes"}`,
})
if err == nil {
t.Fatal("expected notes rejection for key-result")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--notes" {
t.Fatalf("expected param --notes, got: %v", err)
}
}
func TestCreateValidate_RejectCategoryIDForKeyResult(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--objective-id", "456",
"--content", validCreateSimpleJSON,
"--category-id", "123",
})
if err == nil {
t.Fatal("expected category-id rejection for key-result")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--category-id" {
t.Fatalf("expected param --category-id, got: %v", err)
}
}
func TestCreateValidate_ContentAndNotesCannotBothReadStdin(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcutWithStdin(t, f, stdout, `{"text":"stdin content"}`, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", "-",
"--notes", "-",
})
if err == nil {
t.Fatal("expected duplicate stdin error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--notes" {
t.Fatalf("expected param --notes, got: %v", err)
}
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Fatalf("expected duplicate stdin error, got: %v", err)
}
}
func TestCreateValidate_InvalidObjectiveID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--objective-id", "0",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected invalid objective-id error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected typed invalid argument error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got: %v", err)
}
}
func TestCreateValidate_InvalidStyle(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "invalid",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected invalid style error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--style" {
t.Fatalf("expected param --style, got: %v", err)
}
}
func TestCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
"--user-id-type", "invalid",
})
if err == nil {
t.Fatal("expected invalid user-id-type error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--user-id-type" {
t.Fatalf("expected param --user-id-type, got: %v", err)
}
}
func TestCreateValidate_MissingContent(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
})
if err == nil || !strings.Contains(err.Error(), "content") {
t.Fatalf("expected required content error, got: %v", err)
}
}
func TestCreateValidate_InvalidSimpleContentJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "simple",
"--content", "not-json",
})
if err == nil {
t.Fatal("expected invalid simple json error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
}
func TestCreateValidate_EmptySimpleText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "simple",
"--content", `{"text":" "}`,
})
if err == nil {
t.Fatal("expected empty simple text error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
}
func TestCreateValidate_EmptySimpleMention(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "simple",
"--content", `{"text":"test","mention":[""]}`,
})
if err == nil {
t.Fatal("expected empty simple mention error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
}
func TestCreateValidate_SimpleContentRejectsDocsImages(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "simple",
"--content", `{"text":"test","docs":[{"title":"doc","url":"https://example.com"}],"images":["img"]}`,
})
if err == nil {
t.Fatal("expected docs/images rejection")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
}
func TestCreateValidate_SimpleContentRejectsUnknownFields(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "simple",
"--content", `{"text":"test","mentions":["ou_123"]}`,
})
if err == nil {
t.Fatal("expected unknown simple content field error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
if !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("expected unknown field error, got: %v", err)
}
}
func TestCreateValidate_InvalidRichTextJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "richtext",
"--content", "not-json",
})
if err == nil {
t.Fatal("expected invalid richtext json error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
}
func TestCreateValidate_RichTextRejectsUnknownFields(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "richtext",
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}],"mentions":["ou_123"]}`,
})
if err == nil {
t.Fatal("expected unknown richtext content field error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
if !strings.Contains(err.Error(), "unknown field") {
t.Fatalf("expected unknown field error, got: %v", err)
}
}
func TestCreateValidate_EmptyRichTextContent(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
for _, content := range []string{emptyCreateRichTextJSON, blankCreateRichTextJSON} {
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--style", "richtext",
"--content", content,
})
if err == nil {
t.Fatalf("expected empty richtext error for %s", content)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--content" {
t.Fatalf("expected param --content, got: %v", err)
}
}
}
func TestCreateDryRun_Objective(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
}
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/cycles/123/objectives" {
t.Fatalf("dry-run url = %q, want objective create path; output: %s", got, output)
}
if gjson.Get(output, "data.api.0.params.cycle_id").String() != "123" {
t.Fatalf("expected query params in dry-run, got: %s", output)
}
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "open_id" {
t.Fatalf("expected default user-id-type in dry-run, got: %s", output)
}
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test objective" {
t.Fatalf("dry-run content text = %q, want test objective; output: %s", got, output)
}
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_123" {
t.Fatalf("dry-run mention user_id = %q, want ou_123; output: %s", got, output)
}
}
func TestCreateDryRun_ObjectiveWithNotes(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
"--notes", `{"text":"objective notes","mention":["ou_note"]}`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "objective notes" {
t.Fatalf("dry-run notes text = %q, want objective notes; output: %s", got, output)
}
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
t.Fatalf("dry-run notes mention user_id = %q, want ou_note; output: %s", got, output)
}
}
func TestCreateDryRun_ObjectiveWithCategoryID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
"--category-id", "7249339036661170180",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "7249339036661170180" {
t.Fatalf("dry-run category_id = %q, want 7249339036661170180; output: %s", got, output)
}
}
func TestCreateDryRun_KeyResult(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--objective-id", "456",
"--style", "richtext",
"--content", validCreateRichTextJSON,
"--user-id-type", "union_id",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
}
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/objectives/456/key_results" {
t.Fatalf("dry-run url = %q, want key result create path; output: %s", got, output)
}
if gjson.Get(output, "data.api.0.params.objective_id").String() != "456" {
t.Fatalf("expected objective-id query param in dry-run, got: %s", output)
}
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "union_id" {
t.Fatalf("expected query params in dry-run, got: %s", output)
}
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test content" {
t.Fatalf("dry-run richtext content = %q, want test content; output: %s", got, output)
}
}
func TestCreateExecute_ObjectiveSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "1001",
},
},
})
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
level, _ := data["level"].(string)
if level != "objective" {
t.Fatalf("expected level objective, got %v", data["level"])
}
if data["objective_id"] != "1001" {
t.Fatalf("expected objective_id=1001, got %v", data["objective_id"])
}
}
func TestCreateExecute_KeyResultSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "2001",
},
},
})
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "key-result",
"--objective-id", "456",
"--content", validCreateSimpleJSON,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
level, _ := data["level"].(string)
if level != "key-result" {
t.Fatalf("expected level key-result, got %v", data["level"])
}
if data["key_result_id"] != "2001" {
t.Fatalf("expected key_result_id=2001, got %v", data["key_result_id"])
}
if data["objective_id"] != "456" {
t.Fatalf("expected objective_id=456, got %v", data["objective_id"])
}
}
func TestCreateExecute_ObjectiveAPITypedErrorPassThrough(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
err := runCreateShortcut(t, f, stdout, []string{
"+create",
"--level", "objective",
"--cycle-id", "123",
"--content", validCreateSimpleJSON,
})
if err == nil {
t.Fatal("expected API error")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI {
t.Fatalf("expected typed API error, got: %v", err)
}
}
func TestCreateExecute_KeyResultRawErrorWrappedAsNetworkError(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
raw := errors.New("dial tcp: i/o timeout")
got := wrapOkrNetworkErr(raw, "failed to create key result")
problem, ok := errs.ProblemOf(got)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("expected network transport error, got: %v", got)
}
if !errors.Is(got, raw) {
t.Fatal("expected wrapped raw error to be preserved")
}
if stdout.String() != "" || f == nil {
// keep the test factory referenced so the helper wiring stays exercised
}
}

View File

@@ -64,6 +64,10 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
nowUTC := now.UTC()
// Month cycles only
if cycleStart.AddDate(1, 0, -1) == cycleEnd {
return false
}
// Check time range: now must be >= start and <= end
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
@@ -78,6 +82,7 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
return status == CycleStatusDefault || status == CycleStatusNormal
}
// OKRListCycles
var OKRListCycles = common.Shortcut{
Service: "okr",
Command: "+cycle-list",
@@ -89,7 +94,9 @@ var OKRListCycles = common.Shortcut{
Flags: []common.Flag{
{Name: "user-id", Desc: "user ID", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
{Name: "time-range", Desc: "local post-filter applied after the requested page is fetched. Format: YYYY-MM--YYYY-MM. Leave empty to keep the page unfiltered."},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
{Name: "page-token", Desc: "pagination token from previous response"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
idType := runtime.Str("user-id-type")
@@ -110,18 +117,29 @@ var OKRListCycles = common.Shortcut{
return err
}
}
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
return err
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id": runtime.Str("user-id"),
"user_id_type": runtime.Str("user-id-type"),
"page_size": 100,
"page_size": runtime.Int("page-size"),
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return common.NewDryRunAPI().
GET("/open-apis/okr/v2/cycles").
Params(params).
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
Desc("List one page of OKR cycles for user; --time-range is a local post-filter on the returned page")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
userID := runtime.Str("user-id")
@@ -140,53 +158,35 @@ var OKRListCycles = common.Shortcut{
hasRange = true
}
// Paginated fetch of all cycles
queryParams := map[string]interface{}{
"user_id": userID,
"user_id_type": userIDType,
"page_size": "100",
"page_size": runtime.Int("page-size"),
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams["page_token"] = pageToken
}
var allCycles []Cycle
page := 0
for {
if err := ctx.Err(); err != nil {
return err
}
if page > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var cycle Cycle
if err := json.Unmarshal(raw, &cycle); err != nil {
continue
}
allCycles = append(allCycles, cycle)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var cycle Cycle
if err := json.Unmarshal(raw, &cycle); err != nil {
continue
}
allCycles = append(allCycles, cycle)
}
hasMore, nextPageToken := common.PaginationMeta(data)
// Filter by time-range overlap
var filtered []Cycle
for i := range allCycles {
@@ -212,7 +212,8 @@ var OKRListCycles = common.Shortcut{
runtime.OutFormat(map[string]interface{}{
"cycles": respCycles,
"total": len(respCycles),
"has_more": hasMore,
"page_token": nextPageToken,
"current_active_cycles": currentActiveCycles,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))

View File

@@ -5,6 +5,8 @@ package okr
import (
"bytes"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
@@ -12,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -120,6 +123,27 @@ func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
}
}
func TestCycleListValidate_InvalidPageSize(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--page-size", "101",
})
if err == nil {
t.Fatal("expected error for invalid --page-size")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--page-size" {
t.Fatalf("expected param --page-size, got: %v", err)
}
}
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
@@ -214,6 +238,9 @@ func TestCycleListDryRun(t *testing.T) {
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "\"page_size\": 100") {
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
}
}
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
@@ -234,6 +261,28 @@ func TestCycleListDryRun_WithTimeRange(t *testing.T) {
}
}
func TestCycleListDryRun_WithPagination(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-789",
"--page-size", "20",
"--page-token", "next-page",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "\"page_size\": 20") {
t.Fatalf("dry-run output should contain page_size=20, got: %s", output)
}
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
t.Fatalf("dry-run output should contain page_token, got: %s", output)
}
}
// --- Execute tests ---
func TestCycleListExecute_NoCycles(t *testing.T) {
@@ -454,9 +503,11 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
if len(cycles) != 2 {
t.Fatalf("cycles count = %d, want 2", len(cycles))
}
total, _ := data["total"].(float64)
if int(total) != 2 {
t.Fatalf("total = %v, want 2", total)
if _, ok := data["total"]; ok {
t.Fatal("total should not be present in response")
}
if hasMore, _ := data["has_more"].(bool); hasMore {
t.Fatalf("has_more = %v, want false", hasMore)
}
// Check current_active_cycles - should only contain cycle-active
@@ -555,10 +606,13 @@ func TestCycleListExecute_Pagination(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// First page
var gotQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
OnMatch: func(req *http.Request) {
gotQuery = req.URL.Query()
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
@@ -578,38 +632,31 @@ func TestCycleListExecute_Pagination(t *testing.T) {
},
})
// Second page
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": "cycle-p2",
"start_time": "1738368000000",
"end_time": "1743465600000",
"cycle_status": 1,
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
},
},
},
},
})
err := runCycleListShortcut(t, f, stdout, []string{
"+cycle-list",
"--user-id", "ou-123",
"--page-size", "1",
"--page-token", "start_page",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := gotQuery.Get("page_size"); got != "1" {
t.Fatalf("query page_size = %q, want 1", got)
}
if got := gotQuery.Get("page_token"); got != "start_page" {
t.Fatalf("query page_token = %q, want start_page", got)
}
data := decodeEnvelope(t, stdout)
cycles, _ := data["cycles"].([]interface{})
if len(cycles) != 2 {
t.Fatalf("cycles count = %d, want 2", len(cycles))
if len(cycles) != 1 {
t.Fatalf("cycles count = %d, want 1", len(cycles))
}
if hasMore, _ := data["has_more"].(bool); !hasMore {
t.Fatalf("has_more = %v, want true", hasMore)
}
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
t.Fatalf("page_token = %q, want next_page", pageToken)
}
}

View File

@@ -28,6 +28,8 @@ var OKRListProgress = common.Shortcut{
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "department-id-type", Default: "open_department_id", Desc: "department ID type: department_id | open_department_id"},
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
{Name: "page-token", Desc: "pagination token from previous response"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
targetID := runtime.Str("target-id")
@@ -55,6 +57,14 @@ var OKRListProgress = common.Shortcut{
if deptIDType != "department_id" && deptIDType != "open_department_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
}
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
return err
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
return err
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -63,7 +73,10 @@ var OKRListProgress = common.Shortcut{
params := map[string]interface{}{
"user_id_type": runtime.Str("user-id-type"),
"department_id_type": runtime.Str("department-id-type"),
"page_size": 100,
"page_size": runtime.Int("page-size"),
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
switch targetType {
@@ -91,7 +104,10 @@ var OKRListProgress = common.Shortcut{
queryParams := map[string]interface{}{
"user_id_type": userIDType,
"department_id_type": deptIDType,
"page_size": "100",
"page_size": runtime.Int("page-size"),
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams["page_token"] = pageToken
}
var apiPath string
@@ -103,36 +119,29 @@ var OKRListProgress = common.Shortcut{
}
var allProgress []*Progress
for {
if err := ctx.Err(); err != nil {
return err
}
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var progress Progress
if err := json.Unmarshal(raw, &progress); err != nil {
continue
}
allProgress = append(allProgress, &progress)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
if err := ctx.Err(); err != nil {
return err
}
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var progress Progress
if err := json.Unmarshal(raw, &progress); err != nil {
continue
}
allProgress = append(allProgress, &progress)
}
hasMore, pageToken := common.PaginationMeta(data)
// Convert to response format
respProgress := make([]*RespProgress, 0, len(allProgress))
for _, p := range allProgress {
@@ -141,7 +150,8 @@ var OKRListProgress = common.Shortcut{
runtime.OutFormat(map[string]interface{}{
"progress_list": respProgress,
"total": len(respProgress),
"has_more": hasMore,
"page_token": pageToken,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d progress(es)\n", len(respProgress))
for _, p := range respProgress {

View File

@@ -5,11 +5,14 @@ package okr
import (
"bytes"
"net/http"
"net/url"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -123,6 +126,28 @@ func TestProgressListValidate_InvalidDepartmentIDType(t *testing.T) {
}
}
func TestProgressListValidate_InvalidPageSize(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
err := runProgressListShortcut(t, f, stdout, []string{
"+progress-list",
"--target-id", "123",
"--target-type", "objective",
"--page-size", "0",
})
if err == nil {
t.Fatal("expected error for invalid --page-size")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--page-size" {
t.Fatalf("expected param --page-size, got: %v", err)
}
}
// --- DryRun tests ---
func TestProgressListDryRun_Objective(t *testing.T) {
@@ -144,6 +169,9 @@ func TestProgressListDryRun_Objective(t *testing.T) {
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method, got: %s", output)
}
if !strings.Contains(output, "\"page_size\": 100") {
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
}
}
func TestProgressListDryRun_KeyResult(t *testing.T) {
@@ -164,14 +192,41 @@ func TestProgressListDryRun_KeyResult(t *testing.T) {
}
}
func TestProgressListDryRun_WithPagination(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
err := runProgressListShortcut(t, f, stdout, []string{
"+progress-list",
"--target-id", "123456789",
"--target-type", "objective",
"--page-size", "25",
"--page-token", "next-page",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "\"page_size\": 25") {
t.Fatalf("dry-run output should contain page_size=25, got: %s", output)
}
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
t.Fatalf("dry-run output should contain page_token, got: %s", output)
}
}
// --- Execute tests ---
func TestProgressListExecute_Success_Objective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t))
var gotQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123456789/progresses",
OnMatch: func(req *http.Request) {
gotQuery = req.URL.Query()
},
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
@@ -191,7 +246,8 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
},
},
},
"has_more": false,
"has_more": true,
"page_token": "next_page",
},
},
})
@@ -199,15 +255,32 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
"+progress-list",
"--target-id", "123456789",
"--target-type", "objective",
"--page-size", "50",
"--page-token", "start_page",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := gotQuery.Get("page_size"); got != "50" {
t.Fatalf("query page_size = %q, want 50", got)
}
if got := gotQuery.Get("page_token"); got != "start_page" {
t.Fatalf("query page_token = %q, want start_page", got)
}
data := decodeEnvelope(t, stdout)
records, _ := data["progress_list"].([]interface{})
if len(records) != 1 {
t.Fatalf("expected 1 progress, got %d", len(records))
}
if _, ok := data["total"]; ok {
t.Fatal("total should not be present in response")
}
if hasMore, _ := data["has_more"].(bool); !hasMore {
t.Fatalf("has_more = %v, want true", hasMore)
}
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
t.Fatalf("page_token = %q, want next_page", pageToken)
}
}
func TestProgressListExecute_Success_KeyResult(t *testing.T) {

View File

@@ -18,6 +18,7 @@ func Shortcuts() []common.Shortcut {
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
OKRCreate,
OKRBatchCreate,
OKRReorder,
OKRWeight,

View File

@@ -12,6 +12,12 @@ import (
func TestShortcutsRegistration(t *testing.T) {
convey.Convey("Shortcuts() returns all commands", t, func() {
list := Shortcuts()
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
commands := make([]string, 0, len(list))
for _, shortcut := range list {
commands = append(commands, shortcut.Command)
}
convey.So(commands, convey.ShouldContain, "+create")
convey.So(commands, convey.ShouldContain, "+batch-create")
convey.So(commands, convey.ShouldContain, "+patch")
})
}

View File

@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WhiteboardUpdate,
WhiteboardUpdateOld,
WhiteboardExport,
WhiteboardQuery,
}
}

View File

@@ -0,0 +1,728 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
// WhiteboardExportAsPreview exports a whiteboard preview image.
WhiteboardExportAsPreview = "preview"
// WhiteboardExportAsSvg exports a whiteboard as SVG.
WhiteboardExportAsSvg = "svg"
// WhiteboardExportAsSource exports Mermaid or PlantUML source extracted from the whiteboard.
WhiteboardExportAsSource = "source"
// WhiteboardExportAsRaw exports the raw whiteboard node payload.
WhiteboardExportAsRaw = "raw"
// Legacy output type names accepted for backward compatibility.
WhiteboardQueryAsImage = "image"
// WhiteboardQueryAsSvg is deprecated; use WhiteboardExportAsSvg.
WhiteboardQueryAsSvg = WhiteboardExportAsSvg
WhiteboardQueryAsCode = "code"
// WhiteboardQueryAsRaw is deprecated; use WhiteboardExportAsRaw.
WhiteboardQueryAsRaw = WhiteboardExportAsRaw
)
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
type SyntaxType int
const (
// SyntaxTypePlantUML marks PlantUML code blocks.
SyntaxTypePlantUML SyntaxType = 1
// SyntaxTypeMermaid marks Mermaid code blocks.
SyntaxTypeMermaid SyntaxType = 2
)
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
var SyntaxTypeNameMap = map[SyntaxType]string{
SyntaxTypePlantUML: "plantuml",
SyntaxTypeMermaid: "mermaid",
}
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
var SyntaxTypeExtensionMap = map[SyntaxType]string{
SyntaxTypePlantUML: ".puml",
SyntaxTypeMermaid: ".mmd",
}
// String returns the CLI-facing name for the syntax type.
func (s SyntaxType) String() string {
return SyntaxTypeNameMap[s]
}
// ExtensionName returns the default file extension for the syntax type.
func (s SyntaxType) ExtensionName() string {
return SyntaxTypeExtensionMap[s]
}
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
func (s SyntaxType) IsValid() bool {
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
}
var wbExportScopes = []string{"board:whiteboard:node:read"}
var wbExportAuthTypes = []string{"user", "bot"}
var wbExportFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output-type", Desc: "output whiteboard as: preview | svg | source | raw.", Required: true, Enum: []string{"preview", "svg", "source", "raw"}},
{Name: "output", Desc: "output path. It is required when --output-type preview. If not specified when --output-type svg/source/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
}
var wbQueryFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true, Enum: []string{"image", "svg", "code", "raw"}},
{Name: "output", Desc: "output path. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
}
func wbExportOutputType(runtime *common.RuntimeContext) (string, string) {
normalized, ok := normalizeWhiteboardExportOutputType(runtime.Str("output-type"))
if !ok {
return "", "--output-type"
}
return normalized, "--output-type"
}
func wbQueryOutputType(runtime *common.RuntimeContext) (string, string) {
normalized, ok := normalizeLegacyWhiteboardExportOutputType(runtime.Str("output_as"))
if !ok {
return "", "--output_as"
}
return normalized, "--output_as"
}
func normalizeWhiteboardExportOutputType(outputType string) (string, bool) {
switch outputType {
case WhiteboardExportAsPreview:
return WhiteboardExportAsPreview, true
case WhiteboardExportAsSvg:
return WhiteboardExportAsSvg, true
case WhiteboardExportAsSource:
return WhiteboardExportAsSource, true
case WhiteboardExportAsRaw:
return WhiteboardExportAsRaw, true
default:
return "", false
}
}
func normalizeLegacyWhiteboardExportOutputType(outputType string) (string, bool) {
switch outputType {
case WhiteboardQueryAsImage:
return WhiteboardExportAsPreview, true
case WhiteboardQueryAsCode:
return WhiteboardExportAsSource, true
default:
return normalizeWhiteboardExportOutputType(outputType)
}
}
func wbExportOutputTypeError(param string) *errs.ValidationError {
if param == "--output_as" {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--output_as flag must be one of: image | svg | code | raw",
).WithParam("--output_as")
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--output-type flag must be one of: preview | svg | source | raw",
).WithParam("--output-type")
}
func wbExportValidate(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportValidateWithOutputType(ctx, runtime, wbExportOutputType)
}
func wbQueryValidate(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportValidateWithOutputType(ctx, runtime, wbQueryOutputType)
}
func wbExportValidateWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
// Check if token contains control characters
token := runtime.Str("whiteboard-token")
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
return err
}
outputType, outputTypeParam := outputTypeFn(runtime)
if outputType == "" {
return wbExportOutputTypeError(outputTypeParam)
}
out := runtime.Str("output")
if out != "" {
if _, err := runtime.ResolveSavePath(out); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
}
if out == "" && outputType == WhiteboardExportAsPreview {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output path to export whiteboard as preview").WithParam("--output")
}
return nil
}
func wbExportDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return wbExportDryRunWithOutputType(ctx, runtime, wbExportOutputType)
}
func wbQueryDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return wbExportDryRunWithOutputType(ctx, runtime, wbQueryOutputType)
}
func wbExportDryRunWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) *common.DryRunAPI {
outputType, outputTypeParam := outputTypeFn(runtime)
token := runtime.Str("whiteboard-token")
switch outputType {
case WhiteboardExportAsPreview:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
Desc("Export preview image of given whiteboard")
case WhiteboardExportAsSource:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract Mermaid/Plantuml source from given whiteboard")
case WhiteboardExportAsRaw:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardExportAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
if outputTypeParam == "--output_as" {
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
}
return common.NewDryRunAPI().Desc("invalid --output-type flag, must be one of: preview | svg | source | raw")
}
}
func wbExportExecute(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportExecuteWithOutputType(ctx, runtime, wbExportOutputType)
}
func wbQueryExecute(ctx context.Context, runtime *common.RuntimeContext) error {
return wbExportExecuteWithOutputType(ctx, runtime, wbQueryOutputType)
}
func wbExportExecuteWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
token := runtime.Str("whiteboard-token")
outDir := runtime.Str("output")
outputType, outputTypeParam := outputTypeFn(runtime)
switch outputType {
case WhiteboardExportAsPreview:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardExportAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardExportAsSource:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardExportAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return wbExportOutputTypeError(outputTypeParam)
}
}
const WhiteboardExportDescription = "Export an existing whiteboard as preview image, SVG, source code or raw nodes structure."
// WhiteboardExport registers the `whiteboard +export` shortcut.
var WhiteboardExport = common.Shortcut{
Service: "whiteboard",
Command: "+export",
Description: WhiteboardExportDescription,
Risk: "read",
Scopes: wbExportScopes,
AuthTypes: wbExportAuthTypes,
Flags: wbExportFlags,
HasFormat: true,
Validate: wbExportValidate,
DryRun: wbExportDryRun,
Execute: wbExportExecute,
}
// WhiteboardQuery registers the hidden, backward-compatible `whiteboard +query` shortcut.
var WhiteboardQuery = common.Shortcut{
Service: "whiteboard",
Command: "+query",
Description: WhiteboardExportDescription,
Risk: "read",
Scopes: wbExportScopes,
AuthTypes: wbExportAuthTypes,
Flags: wbQueryFlags,
HasFormat: true,
Hidden: true,
Validate: wbQueryValidate,
DryRun: wbQueryDryRun,
Execute: wbQueryExecute,
}
// exportReq defines the request body for whiteboard export APIs.
type exportReq struct {
ExportType string `json:"export_type"`
}
// exportResp models the whiteboard export response envelope.
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
if exportData.Code != 0 {
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
}
} else if resp.StatusCode == http.StatusOK {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
}
if resp.StatusCode != http.StatusOK {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
}
// Execute API request. The preview endpoint streams raw image bytes (not a
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
// while 4xx remains an API-side rejection.
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
if err != nil {
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
}
if resp.StatusCode >= 400 {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
finalPath, size, err := saveWhiteboardPreviewOutput(outDir, wbToken, runtime, resp.Header, bytes.NewReader(resp.RawBody))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"preview_image_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
fmt.Fprintf(w, "Image size: %d bytes", size)
})
return nil
}
type wbNodesResp struct {
Data struct {
Nodes []interface{} `json:"nodes"`
} `json:"data"`
}
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
if err != nil {
return nil, err
}
var nodes wbNodesResp
rawNodes, _ := data["nodes"]
if rawNodes != nil {
var ok bool
nodes.Data.Nodes, ok = rawNodes.([]interface{})
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
}
}
return &nodes, nil
}
type syntaxInfo struct {
code string
syntaxType SyntaxType
}
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
var syntaxBlocks []syntaxInfo
for _, node := range wbNodes.Data.Nodes {
nodeMap, ok := node.(map[string]interface{})
if !ok {
continue
}
syntax, ok := nodeMap["syntax"]
if !ok {
continue
}
syntaxMap, ok := syntax.(map[string]interface{})
if !ok {
continue
}
code, _ := syntaxMap["code"].(string)
var syntaxType SyntaxType
switch v := syntaxMap["syntax_type"].(type) {
case json.Number:
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
// so numeric fields arrive as json.Number rather than float64.
if n, err := v.Int64(); err == nil {
syntaxType = SyntaxType(n)
}
case float64:
syntaxType = SyntaxType(v)
case SyntaxType:
syntaxType = v
}
if code != "" && syntaxType.IsValid() {
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
}
}
if len(syntaxBlocks) == 0 {
runtime.OutFormat(map[string]interface{}{
"msg": "no code blocks found in whiteboard",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
})
return nil
}
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
// 如果有需求,可以调整到导出到多个文件的模式
if len(syntaxBlocks) > 1 {
runtime.OutFormat(map[string]interface{}{
"msg": "multiple code blocks found, cannot export directly",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
})
return nil
}
block := syntaxBlocks[0]
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"code": block.code,
"syntax_type": block.syntaxType.String(),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", block.code)
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
})
return nil
}
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(jsonData))
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
})
return nil
}
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
// Step 1: Get final output path
info, err := runtime.FileIO().Stat(outPath)
var finalPath string
if err == nil && info.IsDir() {
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
} else {
// Fix extension in path
currentExt := filepath.Ext(outPath)
if currentExt != ext {
if currentExt != "" {
outPath = outPath[:len(outPath)-len(currentExt)]
}
outPath += ext
}
finalPath = outPath
}
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
// Step 2: Check overwrite
_, err = runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
// Step 3: Save file
var contentType string
switch ext {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":
contentType = "text/plain"
}
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: contentType,
}, data)
if err != nil {
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil
}
var whiteboardPreviewContentTypeExt = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
}
func saveWhiteboardPreviewOutput(outPath, token string, runtime *common.RuntimeContext, header http.Header, data io.Reader) (string, int64, error) {
contentType := header.Get("Content-Type")
ext, err := whiteboardPreviewExtFromContentType(contentType)
if err != nil {
return "", 0, err
}
finalPath, err := whiteboardPreviewOutputPath(outPath, ext, token, runtime)
if err != nil {
return "", 0, err
}
return saveResolvedOutputFile(finalPath, contentType, runtime, data)
}
func whiteboardPreviewExtFromContentType(contentType string) (string, error) {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := whiteboardPreviewContentTypeExt[strings.ToLower(mediaType)]; ok {
return ext, nil
}
if strings.TrimSpace(contentType) == "" {
contentType = "<empty>"
}
return "", errs.NewInternalError(
errs.SubtypeInvalidResponse,
"get whiteboard preview failed: expected image/png or image/jpeg response, got Content-Type: %s",
contentType,
)
}
func whiteboardPreviewOutputPath(outPath, ext, token string, runtime *common.RuntimeContext) (string, error) {
info, err := runtime.FileIO().Stat(outPath)
if err == nil && info.IsDir() {
finalPath := filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
return finalPath, nil
}
if err != nil && !os.IsNotExist(err) {
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot check output path: %s", err).WithCause(err)
}
currentExt := strings.ToLower(filepath.Ext(outPath))
if currentExt == "" || currentExt == "." {
finalPath := strings.TrimSuffix(outPath, ".") + ext
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
return finalPath, nil
}
if !isWhiteboardPreviewImageExt(currentExt) {
return "", errs.NewValidationError(
errs.SubtypeInvalidArgument,
"invalid preview output extension %q; use .png, .jpg, .jpeg, a directory, or a path without extension",
currentExt,
).WithParam("--output")
}
if !whiteboardPreviewExtMatches(currentExt, ext) {
return "", errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"preview response is %s but output path has extension %s; use a matching extension or omit the extension",
ext,
currentExt,
).WithParam("--output")
}
if _, err := runtime.ResolveSavePath(outPath); err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
return outPath, nil
}
func isWhiteboardPreviewImageExt(ext string) bool {
return ext == ".png" || ext == ".jpg" || ext == ".jpeg"
}
func whiteboardPreviewExtMatches(outputExt, responseExt string) bool {
if responseExt == ".jpg" {
return outputExt == ".jpg" || outputExt == ".jpeg"
}
return outputExt == responseExt
}
func saveResolvedOutputFile(finalPath, contentType string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
_, err := runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: contentType,
}, data)
if err != nil {
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil
}

View File

@@ -9,6 +9,7 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
@@ -211,6 +212,73 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
}
}
// TestWhiteboardExport_Validate verifies the canonical +export flag spelling
// and output type names while legacy +query validation remains covered above.
func TestWhiteboardExport_Validate(t *testing.T) {
ctx := context.Background()
chdirTemp(t)
tests := []struct {
name string
flags map[string]string
wantErr bool
wantParam string
}{
{
name: "valid: preview with output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "preview",
"output": "output",
},
},
{
name: "valid: source without output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "source",
},
},
{
name: "invalid: preview without output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "preview",
},
wantErr: true,
wantParam: "--output",
},
{
name: "invalid: bad output-type value",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output-type": "image",
},
wantErr: true,
wantParam: "--output-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WhiteboardExport.Validate(ctx, newTestRuntime(tt.flags, nil))
if (err != nil) != tt.wantErr {
t.Fatalf("WhiteboardExport.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil {
return
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T", err)
}
if ve.Param != tt.wantParam {
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
}
})
}
}
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
// a flat legacy error.
@@ -284,7 +352,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output": "output.png",
},
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
wantPath: "/open-apis/board/v1/whiteboards/test...-123/download_as_image",
},
{
name: "dry run code",
@@ -293,7 +361,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output_as": "code",
},
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
},
{
name: "dry run raw",
@@ -302,7 +370,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
"output_as": "raw",
},
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
},
}
@@ -313,6 +381,29 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
if dryRun == nil {
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal() error = %v; data=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; data=%s", len(got.API), string(data))
}
if got.API[0].Method != tt.wantMethod {
t.Fatalf("method = %q, want %q; data=%s", got.API[0].Method, tt.wantMethod, string(data))
}
if got.API[0].URL != tt.wantPath {
t.Fatalf("url = %q, want %q; data=%s", got.API[0].URL, tt.wantPath, string(data))
}
})
}
}
@@ -391,6 +482,32 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
if len(WhiteboardQuery.Flags) == 0 {
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
}
if !WhiteboardQuery.Hidden {
t.Errorf("WhiteboardQuery should be hidden because +export is the canonical command")
}
// Verify WhiteboardExport is the visible canonical shortcut.
if WhiteboardExport.Command != "+export" {
t.Errorf("WhiteboardExport.Command = %q, want \"+export\"", WhiteboardExport.Command)
}
if WhiteboardExport.Service != "whiteboard" {
t.Errorf("WhiteboardExport.Service = %q, want \"whiteboard\"", WhiteboardExport.Service)
}
if WhiteboardExport.Hidden {
t.Errorf("WhiteboardExport should be visible")
}
if flag := shortcutFlag(WhiteboardExport, "output_as"); flag != nil {
t.Errorf("WhiteboardExport --output_as should not be registered; got %#v", *flag)
}
if flag := shortcutFlag(WhiteboardExport, "output-type"); flag == nil || flag.Hidden {
t.Errorf("WhiteboardExport --output-type should exist and be visible")
}
if flag := shortcutFlag(WhiteboardQuery, "output_as"); flag == nil || flag.Hidden {
t.Errorf("WhiteboardQuery --output_as should exist and remain visible on the hidden legacy command")
}
if flag := shortcutFlag(WhiteboardQuery, "output-type"); flag != nil {
t.Errorf("WhiteboardQuery --output-type should not be registered; got %#v", *flag)
}
}
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
@@ -862,10 +979,11 @@ func TestExportWhiteboardPreview(t *testing.T) {
// Mock download preview image API response with RawBody
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
Status: 200,
RawBody: []byte("fake PNG image data"),
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
Status: 200,
RawBody: []byte("fake PNG image data"),
ContentType: "image/png",
})
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
@@ -883,6 +1001,158 @@ func TestExportWhiteboardPreview(t *testing.T) {
}
}
// TestExportWhiteboardPreview_UsesContentTypeExtension verifies preview image
// downloads are saved according to the API response Content-Type rather than a
// hard-coded PNG suffix.
func TestExportWhiteboardPreview_UsesContentTypeExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-jpeg/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
ContentType: "image/jpeg",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-jpeg", "--output-type", "preview", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat("output.png"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("output.png should not exist when response Content-Type is image/jpeg, stat err=%v", err)
}
data, err := os.ReadFile("output.jpg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "fake JPEG image data" {
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
}
}
func TestExportWhiteboardPreview_RejectsNonImageContentTypeWithoutSiblingOverwrite(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
if err := os.WriteFile("report.html", []byte("keep me"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-html/download_as_image",
Status: 200,
RawBody: []byte("<html>bad gateway</html>"),
ContentType: "text/html; charset=utf-8",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-html", "--output-type", "preview", "--output", "report.png", "--overwrite"}
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
if err == nil {
t.Fatal("expected error for non-image preview response")
}
assertInvalidResponse(t, err)
data, readErr := os.ReadFile("report.html")
if readErr != nil {
t.Fatalf("ReadFile() error: %v", readErr)
}
if string(data) != "keep me" {
t.Fatalf("report.html was overwritten: %q", string(data))
}
if _, statErr := os.Stat("report.png"); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("report.png should not be written on invalid response, stat err=%v", statErr)
}
}
func TestExportWhiteboardPreview_IgnoresContentDispositionExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-disposition/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
Headers: http.Header{
"Content-Type": []string{"image/jpeg"},
"Content-Disposition": []string{`attachment; filename="payload.sh"`},
},
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-disposition", "--output-type", "preview", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if _, err := os.Stat("output.sh"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("output.sh should not be created from Content-Disposition, stat err=%v", err)
}
data, err := os.ReadFile("output.jpg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "fake JPEG image data" {
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
}
}
func TestExportWhiteboardPreview_RejectsMismatchedExplicitExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-mismatch/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
ContentType: "image/jpeg",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-mismatch", "--output-type", "preview", "--output", "report.png", "--overwrite"}
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
if err == nil {
t.Fatal("expected error for mismatched explicit extension")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition || ve.Param != "--output" {
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeFailedPrecondition)
}
if _, statErr := os.Stat("report.jpg"); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("report.jpg should not be created when explicit path mismatches, stat err=%v", statErr)
}
}
func TestExportWhiteboardPreview_AllowsMatchingExplicitExtension(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/board/v1/whiteboards/test-token-preview-matching/download_as_image",
Status: 200,
RawBody: []byte("fake JPEG image data"),
ContentType: "image/jpeg",
})
args := []string{"+export", "--whiteboard-token", "test-token-preview-matching", "--output-type", "preview", "--output", "report.jpeg", "--overwrite"}
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data, err := os.ReadFile("report.jpeg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != "fake JPEG image data" {
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
}
}
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -1522,3 +1792,12 @@ func chdirTemp(t *testing.T) {
}
t.Cleanup(func() { os.Chdir(orig) })
}
func shortcutFlag(shortcut common.Shortcut, name string) *common.Flag {
for i := range shortcut.Flags {
if shortcut.Flags[i].Name == name {
return &shortcut.Flags[i]
}
}
return nil
}

View File

@@ -1,494 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
// WhiteboardQueryAsImage exports a whiteboard preview image.
WhiteboardQueryAsImage = "image"
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
WhiteboardQueryAsSvg = "svg"
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
WhiteboardQueryAsCode = "code"
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
WhiteboardQueryAsRaw = "raw"
)
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
type SyntaxType int
const (
// SyntaxTypePlantUML marks PlantUML code blocks.
SyntaxTypePlantUML SyntaxType = 1
// SyntaxTypeMermaid marks Mermaid code blocks.
SyntaxTypeMermaid SyntaxType = 2
)
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
var SyntaxTypeNameMap = map[SyntaxType]string{
SyntaxTypePlantUML: "plantuml",
SyntaxTypeMermaid: "mermaid",
}
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
var SyntaxTypeExtensionMap = map[SyntaxType]string{
SyntaxTypePlantUML: ".puml",
SyntaxTypeMermaid: ".mmd",
}
// String returns the CLI-facing name for the syntax type.
func (s SyntaxType) String() string {
return SyntaxTypeNameMap[s]
}
// ExtensionName returns the default file extension for the syntax type.
func (s SyntaxType) ExtensionName() string {
return SyntaxTypeExtensionMap[s]
}
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
func (s SyntaxType) IsValid() bool {
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
}
// WhiteboardQuery registers the `whiteboard +query` shortcut.
var WhiteboardQuery = common.Shortcut{
Service: "whiteboard",
Command: "+query",
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
Risk: "read",
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
},
HasFormat: true,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Check if token contains control characters
token := runtime.Str("whiteboard-token")
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
return err
}
out := runtime.Str("output")
if out != "" {
if _, err := runtime.ResolveSavePath(out); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
}
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
as := runtime.Str("output_as")
token := runtime.Str("whiteboard-token")
switch as {
case WhiteboardQueryAsImage:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
Desc("Export preview image of given whiteboard")
case WhiteboardQueryAsCode:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract Mermaid/Plantuml code from given whiteboard")
case WhiteboardQueryAsRaw:
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardQueryAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// 构建 API 请求
token := runtime.Str("whiteboard-token")
outDir := runtime.Str("output")
as := runtime.Str("output_as")
switch as {
case WhiteboardQueryAsImage:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardQueryAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardQueryAsCode:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
}
},
}
// exportReq defines the request body for whiteboard export APIs.
type exportReq struct {
ExportType string `json:"export_type"`
}
// exportResp models the whiteboard export response envelope.
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
if exportData.Code != 0 {
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
}
} else if resp.StatusCode == http.StatusOK {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
}
if resp.StatusCode != http.StatusOK {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
}
// Execute API request. The preview endpoint streams raw image bytes (not a
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
// while 4xx remains an API-side rejection.
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
if err != nil {
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
}
if resp.StatusCode >= 400 {
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
if resp.StatusCode >= 500 {
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode).
WithRetryable()
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
WithCode(resp.StatusCode)
}
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"preview_image_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
fmt.Fprintf(w, "Image size: %d bytes", size)
})
return nil
}
type wbNodesResp struct {
Data struct {
Nodes []interface{} `json:"nodes"`
} `json:"data"`
}
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
if err != nil {
return nil, err
}
var nodes wbNodesResp
rawNodes, _ := data["nodes"]
if rawNodes != nil {
var ok bool
nodes.Data.Nodes, ok = rawNodes.([]interface{})
if !ok {
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
}
}
return &nodes, nil
}
type syntaxInfo struct {
code string
syntaxType SyntaxType
}
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
var syntaxBlocks []syntaxInfo
for _, node := range wbNodes.Data.Nodes {
nodeMap, ok := node.(map[string]interface{})
if !ok {
continue
}
syntax, ok := nodeMap["syntax"]
if !ok {
continue
}
syntaxMap, ok := syntax.(map[string]interface{})
if !ok {
continue
}
code, _ := syntaxMap["code"].(string)
var syntaxType SyntaxType
switch v := syntaxMap["syntax_type"].(type) {
case json.Number:
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
// so numeric fields arrive as json.Number rather than float64.
if n, err := v.Int64(); err == nil {
syntaxType = SyntaxType(n)
}
case float64:
syntaxType = SyntaxType(v)
case SyntaxType:
syntaxType = v
}
if code != "" && syntaxType.IsValid() {
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
}
}
if len(syntaxBlocks) == 0 {
runtime.OutFormat(map[string]interface{}{
"msg": "no code blocks found in whiteboard",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
})
return nil
}
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
// 如果有需求,可以调整到导出到多个文件的模式
if len(syntaxBlocks) > 1 {
runtime.OutFormat(map[string]interface{}{
"msg": "multiple code blocks found, cannot export directly",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
})
return nil
}
block := syntaxBlocks[0]
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"code": block.code,
"syntax_type": block.syntaxType.String(),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", block.code)
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
})
return nil
}
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
if err != nil {
return err
}
if wbNodes == nil || wbNodes.Data.Nodes == nil {
runtime.OutFormat(map[string]interface{}{
"msg": "whiteboard is empty",
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard is empty\n")
})
return nil
}
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
if err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
}
if outDir == "" {
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(jsonData))
})
return nil
}
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"output_path": finalPath,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
})
return nil
}
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
// Step 1: Get final output path
info, err := runtime.FileIO().Stat(outPath)
var finalPath string
if err == nil && info.IsDir() {
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
} else {
// Fix extension in path
currentExt := filepath.Ext(outPath)
if currentExt != ext {
if currentExt != "" {
outPath = outPath[:len(outPath)-len(currentExt)]
}
outPath += ext
}
finalPath = outPath
}
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
}
// Step 2: Check overwrite
_, err = runtime.FileIO().Stat(finalPath)
if err == nil {
if !runtime.Bool("overwrite") {
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
}
} else if !os.IsNotExist(err) {
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
}
// Step 3: Save file
var contentType string
switch ext {
case ".png":
contentType = "image/png"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":
contentType = "text/plain"
}
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: contentType,
}, data)
if err != nil {
return "", 0, wbSaveError(err)
}
return finalPath, savResult.Size(), nil
}

View File

@@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
got := Shortcuts()
want := []string{
"+update",
"+export",
"+query",
}

View File

@@ -112,7 +112,7 @@ metadata:
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list``+field-search-options` 确认可选值。
## 表单与视图细节

View File

@@ -9,7 +9,7 @@
- `--json` 必须是 JSON 对象。
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`
- `+record-batch-create``rows``CellValue[][]`,列顺序由 `fields` 决定。
- `+record-batch-update``patch``Map<FieldNameOrID, CellValue>`,同一份 `patch` 会应用到所有 `record_id_list`
- `+record-batch-update`使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`
- 一次 payload 里同一字段只用一种 key字段名或字段 ID不要重复。
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
- 需要清空字段时优先传 `null`(字段允许清空时)。

View File

@@ -87,16 +87,20 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
## 返回重点
- 返回 `field``created: true`
- 如果返回 `field_get_recommended:false``next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`
- 如果返回 `field_get_recommended:true``next_step:"field_get"`,按 `verification_hint` 读回字段;`formula``lookup``link``auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。
## 工作流
1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。
2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`
## 坑点
- ⚠️ 这是写入操作,执行前必须确认。
- ⚠️ 当 `type``formula``lookup` 时,先读对应 guide再创建。
- ⚠️ 不要把“每次创建后都 `+field-get`”当作固定流程;按返回里的 `field_get_recommended``next_step` 决定是否读回。
## 参考

View File

@@ -180,11 +180,11 @@
支持字段:`icon``min``max`
默认值 / 约束
默认值 / 已知平台范围
- `icon` 默认 `star`
- `icon` 可用:`star``heart``thumbsup``fire``smile``lightning``flower``number`
- `min` 取值 `0..1`,默认 `1`
- `max` 取值 `1..10`默认 `5`
- `max` 默认 `5`;常见或已文档化的范围为 `1..10`,但 CLI 不强制上限为 `10`。如果用户明确需要更大评分范围,优先确认平台能力或用 `+field-create/update --dry-run` 检查请求形状;平台拒绝后再建议改用普通数字或进度字段。
```json
{
@@ -419,7 +419,7 @@
### 3.11 auto_number
自动编号字段;不写 `style.rules` 使用默认规则:`NO.001`
自动编号字段;创建时不写 `style.rules` 使用默认规则:`NO.001`更新已有自动编号字段时应显式提交目标 `style.rules`,因为 `+field-update` 会把新的编号规则重新应用到已有编号。
最小写法:
@@ -512,7 +512,7 @@
## 4. 创建与更新
- `+field-create`:按目标字段配置直接构造 `--json`
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes``type``auto_number` 时,更新编号规则本身就会把新规则应用到已有编号,无需额外参数,也不要在 JSON 里塞额外的底层实现参数。
## 5. 暂不支持字段

View File

@@ -20,6 +20,13 @@ lark-cli base +field-update \
--field-id <field_id> \
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
--yes
lark-cli base +field-update \
--base-token <base_token> \
--table-id <table_id> \
--field-id <field_id> \
--json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' \
--yes
```
## 参数
@@ -42,6 +49,8 @@ lark-cli base +field-update \
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
```
`--json.type``auto_number` 时,仍然走同一个 v3 字段更新接口:更新自动编号规则后,接口现状就会把新规则应用到已有编号(这是接口默认行为,只是 agent 通常不知道),因此**不需要**任何额外开关或参数。只需要正常提交目标自动编号字段定义即可;如果用户要求“将修改用于已有编号”,直接执行这次 `+field-update` 就能达到效果,不要在 `--json` 里额外添加任何参数去“触发”重排。
## JSON 值规范
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
@@ -52,6 +61,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
- `link` 更新限制:
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`
- 现有 `link` 字段的 `bidirectional` 不能改。
- `auto_number` 更新的 `style.rules` 支持 `text``created_time``incremental_number`
**推荐更新示例**
@@ -83,13 +93,18 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
## 返回重点
- 返回 `field``updated: true`
- `updated:true` 只表示更新请求成功,不表示字段结构、已有记录值或下游能力已经完成验证。`+field-update` 无法知道更新前的字段类型,因此成功响应会推荐执行 `+field-get`;若发生类型转换,还要抽样读取记录值。
- 如果响应中的 `field.type` 与提交的 `type` 不一致,必须把它当作待核验的类型不匹配;不能返回完成态,也不能只根据其中任一类型推断更新成功。
- 如果 API 报告本次更新没有产生任何变更no-op命令会如实返回该错误这通常说明目标字段已是期望状态不要机械重试同一份 `+field-update`。需要确认当前字段完整状态时执行 `+field-get`
- 如果返回 `field_get_recommended:true``next_step:"field_get"`,按提示读回字段;`auto_number` 更新后还应抽样读记录值确认编号已按新规则生成。
## 工作流
1. 建议先用 `+field-get` 拉现状,再做最小化修改。
2. `formula/lookup` 类型更新前先阅读对应指南。
3. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全
3. 如果更新 `auto_number`,理解为“更新编号规则,同时把新规则应用到已有编号”;执行后按返回提示读回字段并在必要时抽样记录值
4. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
## 字段类型变更规则
@@ -155,6 +170,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
### 完成态验证
- `FieldReadback`: 读回字段结构,确认 `type` / `multiple` / `style` / `options`
- `NoopReadback`: `+field-update` 返回 no-op 错误时,只能说明 API 报告没有产生变更;可以跳过重复 update但不能替代 `FieldReadback`
- `ValueReadback`: 抽样读回转换后的单元格值
- `DownstreamReadback`: 若涉及看板 / 分组 / 排序 / lookup / 公式,继续读回结果
- `CompletionRule`: 结构、值、下游能力都正确,才能回复“已完成”

View File

@@ -2,13 +2,13 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
批量更新记录(将同一份 `patch` 批量应用到一批 `record_id_list`
通过 `update_records` 为每条记录提交字段值
## 推荐命令
```bash
lark-cli base +record-batch-update --base-token <base_token> --table-id <table_id> \
--json '{"record_id_list":["<record_id>"],"patch":{"状态":"完成"}}'
--json '{"update_records":{"<record_id_a>":{"状态":["完成"]},"<record_id_b>":{"分数":20}}}'
lark-cli base +record-batch-update --base-token <base_token> --table-id <table_id> --json @batch-update.json
```
@@ -29,23 +29,25 @@ lark-cli base +record-batch-update --base-token <base_token> --table-id <table_i
本节只说明 `+record-batch-update` 的外层 JSON 形状CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
对象形态:`{"record_id_list":[...],"patch":{...}}`
对象形态:
```json
{"update_records":{"recA":{"状态":["完成"]},"recB":{"分数":20}}}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `record_id_list` | `string[]` | 是 | 要更新的记录 ID 列表(单次最多 200 条) |
| `patch` | `Map<FieldNameOrID, CellValue>` | 是 | 字段更新对象key 是字段名或字段 IDvalue 是 `CellValue`;同一份 `patch` 会应用到 `record_id_list` 内所有记录 |
| `update_records` | `Map<RecordID, Map<FieldNameOrID, CellValue>>` | 是 | record ID 到字段更新对象的映射(单次最多 200 条) |
## 返回重点
返回 `record_id_list``update`,可选返回 `ignored_fields``update` 可能为空对象。
成功响应只包含可选的 `ignored_fields`没有忽略字段时 `data` 为空对象。请求不会预先校验 record ID 是否存在,因此需要确认实际写入结果时,应再用 `+record-get` 读回目标记录。
## 坑点
- 这是“同值批量更新”:所有 `record_id_list` 都应用同一份 `patch`
- `record_id_list` 最大 200 条,超过会被接口校验拒绝。
- 单次最多更新 200 条记录,超过会被接口校验拒绝
- 命令不会自动做字段/行映射转换,传什么就发什么。
- 如果 `patch` 包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。
- 如果字段映射包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。
## 参考

View File

@@ -44,6 +44,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)

View File

@@ -87,13 +87,21 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \
"document": {
"document_id": "doxcnXXXX",
"revision_id": 12,
"content": "<title>标题</title><p>文档内容...</p>"
"content": "<title>标题</title><p>文档内容...</p>",
"reference_map": {
"<block_type>": {
"<ref>": {
"<real-attr-key>": "<real-attr-value>"
}
}
},
"tips": "<safe replay or degradation guidance>"
}
}
}
```
`content` 的格式由 `--doc-format` 决定`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
`content` 的格式由 `--doc-format` 决定`reference_map` 是正文引用数据的结构化 sidecar一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key``real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content``reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
## 参数

View File

@@ -125,9 +125,9 @@ Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Wor
`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as image \
--output-type preview \
--output ./preview.png
```

View File

@@ -2,6 +2,47 @@
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
## HTML5 block
1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
2. 格式如下:
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="use-iframe" content="true">
<meta name="html-box-height-mode" content="auto">
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
<title></title>
</head>
<body>
...
</body>
</html>
```
### 布局与高度
- `lark-cli` 会读取 `.html` 文件并原样写入 `reference_map`,不会解析或校验 `html-box-height-mode`;创建或更新文档前在 `<head>` 中显式声明 `auto``viewport`
- 生成时只使用 `auto``viewport`,不要臆造 `fixed``initial` 或像素值等其他 mode。
- 文档常见可用宽度约 `820px`;根容器使用 `width: 100%``max-width: 100%``box-sizing: border-box`
四种策略:
1. 内容自然撑开:`auto` + 普通文档流;根容器不设固定高度或 `overflow: hidden`
2. 仅按初始内容定高:`auto` + 首次渲染后不再追加或展开内容。
3. 固定像素操作区:`auto` + 业务容器按场景设置固定的 CSS `height``overflow: auto`;高度数值不写进 meta。
4. 单屏应用:`viewport` + `100vh` + 内部滚动、切页或缩放适用于游戏、幻灯片、Dashboard、canvas 编辑器。
正文需要在飞书文档中完整展开时选 `auto`;内容应在 HTML Block 内滚动时选 `viewport``lark-cli` 不参与页面加载后的高度刷新,不要臆造相关 CLI flag。
### 内容限制
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
## OKR block
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR不构造 Objective/KR/Progress 子树。

View File

@@ -23,7 +23,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
## 行内组件
| 标签 | 说明 | 关键属性 |
|-|-|-|
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id``<cite type="user" user-id="userID"></cite>` |
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
@@ -46,8 +46,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- `<html5-block>``<okr>` — 前者在飞书文档「HTML 块」iframe 中加载单文件 HTML内容可用 HTML 渲染时直接使用;后者创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR。完整语法与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md)。
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
- `<okr>` — 创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR完整结构与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)
# 四、块级复制与移动
@@ -85,6 +85,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
## 用户名写入规则
- 任何包含 `<cite type="user">` 的 XML 在导入、新建或编辑回写时,都必须显式传入 `user-id`;其值为用户的 `open_id`,不得省略。
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
- 典型场景IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。

View File

@@ -26,7 +26,10 @@ metadata:
- 高风险写操作删除、公开权限修改、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。
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search``drive +move` 开始。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps``file_type=apps`),裸 token 调 shortcut 时传 `--type apps`
@@ -135,7 +138,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| Shortcut | 说明 |
|----------|----------|
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象支持 `--edited-since``--created-by-me``--mine``--doc-types` 等扁平 flag区分 original creator 与 owner 语义。 |
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点。 |
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件的 PDF / HTML / 文本 / 图片等预览产物。 |

View File

@@ -190,9 +190,9 @@ lark-cli base +record-list --base-token '<base_token>' --table-id '<table_id>' -
- 若要定位画板内部节点,切到 `lark-whiteboard` 读取 raw 节点结构:
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token '<whiteboard_token>' \
--output_as raw
--output-type raw
```
- 如果 raw 节点中存在唯一匹配 `quote` 的文本节点,可定位到该节点;如果有多个相同文本节点,仍然是弱匹配,需要结合位置、样式、用户描述或人工确认。

View File

@@ -7,6 +7,7 @@
## 快速决策
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
- 用户在修改/重写/更新已有普通文件时,优先使用覆盖上传方式,而不是直接上传一个新文件。
## 命令

View File

@@ -0,0 +1,273 @@
# 主题资料收集工作流:执行
由状态 `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE` 加载。
本文档负责最终写操作确认、目标创建、资源移动、验证、恢复行为、`RollbackSnapshotItem` 和执行日志。不得修改搜索、召回、分类规则或计划 schema。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理写操作确认、高风险操作、身份、认证和权限。
2. 按 [`lark-drive-create-folder.md`](lark-drive-create-folder.md) 创建 Drive 文件夹。
3. 按 [`lark-drive-move.md`](lark-drive-move.md) 执行 Drive 移动。
4. 按 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md) 创建 Wiki 节点。
5. 按 [`../../lark-wiki/references/lark-wiki-move.md`](../../lark-wiki/references/lark-wiki-move.md) 执行 Wiki 移动和 Drive 文档移动到 Wiki。
6. 按 [`../../lark-wiki/references/lark-wiki-move-to-drive.md`](../../lark-wiki/references/lark-wiki-move-to-drive.md) 将 Wiki 节点移出到 Drive 文件夹。
7. 按 [`lark-drive-delete.md`](lark-drive-delete.md) 删除本次 workflow 新建的 Drive 文件夹。
8. 按 [`../../lark-wiki/references/lark-wiki-node-delete.md`](../../lark-wiki/references/lark-wiki-node-delete.md) 删除本次 workflow 新建的 Wiki 节点。
9. 需要轮询异步任务时,按 [`lark-drive-task-result.md`](lark-drive-task-result.md) 执行。
10. `MovePlanItem` schema 由 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) 定义,本文件只消费已确认计划。
## 状态:`CONFIRM_EXECUTION`
进入条件:移动计划已准备,且用户要求执行。
必须:
1. 执行前展示所有写操作类别。
2. 将目标创建和资源移动分开展示。
3. 展示默认纳入的高相关资源。
4. 如有用户选择的中相关资源,也要展示。
5. 展示跳过分组和原因。
6. 明确展示跨容器移动。
7. 展示无移动权限和移动权限未知的资源数量。
8. 请求用户明确确认。
9. 确认前校验每个 `move_resource` 项都包含完整 `command_family``command_args`、权限快照和 `rollback_input`;缺失时必须返回 `PLAN_MOVE` 重新生成计划,不得在执行阶段补猜。
10. 只有 `move_permission_state=movable``target_write_state=confirmed` 的计划项可以列入“将移动”。
11. 对每个 `rollback_supported=false` 的计划项逐项展示标题、当前位置、目标位置、不可恢复原因和影响,不得只展示数量。
### 确认 UI
```text
请确认是否执行以下写操作:
本次搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
将创建:
- 目标名称|父级位置|目标类型
将移动:
- 标题|类型|当前位置|目标位置|原因
不会移动:
- 中相关未选择N 项
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
风险提示:
- 不可自动恢复N 项
- 标题|当前位置|目标位置|不可恢复原因|影响:移动成功后 workflow 无法自动搬回原位置,需要手动处理
- 如果搜索范围是所有当前身份可见资源,移动权限未知项不会移动。
确认后才会创建目标和移动资源。
如果不存在不可自动恢复项,请回复“确认执行”开始写操作。
如果存在不可自动恢复项,请回复“确认执行,包括不可自动恢复项”;普通“确认执行”不满足本次风险确认。
也可以回复“调整计划”返回选择资源,或回复“取消”结束流程。
```
如果用户修改选择或相关性分组,废弃当前 `move_plan_items` 并返回 `PLAN_MOVE` 重新生成计划;不得在 `CONFIRM_EXECUTION` 直接局部改写计划。
## 状态:`EXECUTE`
进入条件:用户明确确认写操作;存在 `rollback_supported=false` 的计划项时,用户已明确确认包括不可自动恢复项。
必须:
1. 只执行已确认 `MovePlanItem.command_family``command_args`;不得回查 `ResourceItem` 补齐或改写命令参数。
2. 当存在 `action_type=create_target``MovePlanItem` 时,先创建目标。
3. 目标创建后记录返回 token只允许把 `created_by_plan:<create_target plan_id>` 引用解析为该 token并把解析后的实际参数写入 `execution_journal`。不得重新搜索或猜测目标。
4. 目标 token 引用解析成功后再移动依赖该目标的资源;解析失败时停止依赖该创建目标的移动并记录 blocker不得替换为其他目标。
5. 执行任何写操作前,基于每个已确认计划项的 `rollback_input` 生成 `rollback_snapshot``rollback_supported=false` 且已有明确 `rollback_blocker` 的快照视为完整风险快照,不阻塞其他项。
6. 执行任何写操作前,初始化 `execution_journal`
7. 每次写操作尝试后记录 `execution_journal`
8. 单项失败后可继续执行相互独立的移动;目标创建失败时必须停止。
9. 不得移动 `permission_denied``no_move_permission``move_permission_unknown``unverifiable``low``unsupported_move_target` 项。
10. 不得移动 `move_permission_state!=movable``target_write_state!=confirmed` 的资源。
11. 如果移动命令返回权限错误,记录失败原因,不自动申请权限,不自动重试同一移动。
12. 如果 `rollback_supported=true``rollback_input` 缺少恢复所需字段,将该计划项标记为 `failed` / `plan_snapshot_incomplete` 并跳过;不得在未重新确认风险的情况下把它静默降级为不可恢复项,也不得阻塞其他独立项。
### 移动方式选择
| 来源 -> 目标 | 移动方式 |
|------------------|-------------|
| Drive resource -> Drive folder | `drive +move` |
| Drive document-like resource -> Wiki target | `wiki +move` 的 docs-to-wiki 模式;默认不可自动恢复 |
| Wiki node -> Wiki target | `wiki +move --node-token` |
| Wiki node -> Drive folder | `wiki +move-to-drive` |
### 执行顺序
1. 如有 `create_target` 项,先执行。
2. 按确认计划顺序执行 `move_resource` 项。
3. 如果命令返回 task ID执行异步任务轮询。
4. 输出写操作执行摘要。
### 进度 UI
批量较大时,按计数汇报进度:
```text
执行进度:已完成 <done_count>/<total_count>,成功 <success_count>,失败 <failed_count>。
当前操作:<title>
继续执行中,不需要你操作;如遇到需要确认的失败会单独提示。
```
## 状态:`VERIFY`
进入条件:执行完成。
必须:
1. 如果创建了目标,验证目标存在。
2. 能力支持时,验证已移动资源在目标位置可见。
3. 对比实际位置和 `move_plan_items`
4. 为每一项标记验证状态。
5. 只有当已有移动成功且存在严重不一致或失败时,才提供恢复选项。
6. 输出验证结果时,必须说明用户下一步可以结束流程、查看失败项,或在可恢复时选择恢复。
7. 如果出现 `async_pending`,先使用 `drive +task_result` 轮询确认;超过轮询限制后再报告 pending blocker。
### 验证结果
| 状态值 | 说明 |
|--------|------|
| `verified` | 资源已在目标位置可见。 |
| `not_found` | 目标位置未找到资源。 |
| `permission_unknown` | 当前身份无法确认结果。 |
| `async_pending` | 异步任务尚未完成,需要继续轮询。 |
| `failed` | 移动命令失败或结果不符合计划。 |
## 状态:`RESTORE`
进入条件:失败、不一致或用户明确要求恢复。
必须:
1. 只基于 `rollback_snapshot``execution_journal` 生成恢复计划。
2. 展示可恢复项和不可恢复项。
3. 执行恢复写操作前请求明确确认;确认内容必须包含反向移动和删除本次 workflow 新建目标。
4. 只恢复本次 workflow 移动过的资源。
5. 只恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
6. Drive / Wiki 跨容器移动、原父级 token 缺失等 `rollback_supported=false` 的项不得反向移动,也不得删除迁入后的文档。
7. 本次 workflow 成功创建的目标文件夹或 Wiki 节点必须纳入清理计划。
8. 删除 workflow 新建的 Wiki 目标节点时,必须使用 `wiki +node-delete --include-children=false --yes`,让已迁入的直接子文档保留到该节点父级层级。
9. 删除 workflow 新建的 Drive 文件夹前,必须先恢复或移出其中由本次 workflow 放入的资源;如果无法确认文件夹已安全可删,报告清理阻塞,不得用删除文件夹来删除用户资源。
### 恢复顺序
1. 先恢复 `rollback_supported=true``rollback_eligible=true` 的移动项。
2. 对全部 `rollback_supported=false` 的项,只记录“保留在当前目标位置,不回迁、不删除”和对应 blocker。
3. 再清理 `created_by_workflow=true` 的目标容器。
4. Wiki 新建目标清理使用 `--include-children=false`Drive 新建目标清理只在不会删除用户资源时执行。
### 恢复 UI
```text
可以尝试恢复本次已移动的资源:
可恢复:
- 标题|当前位置|原位置
不可自动恢复:
- 标题|当前位置|原位置|原因|影响:需要手动恢复
将清理本次新建目标:
- 名称|类型|清理方式
将保留在当前目标位置的跨容器迁入文档:
- 标题|当前位置|保留结果
是否执行恢复?
```
## RollbackSnapshotItem
```json
{
"snapshot_id": "稳定快照行 ID",
"plan_id": "对应 MovePlanItem.plan_id",
"resource_id": "对应 MovePlanItem.resource_id",
"source_kind": "drive|wiki",
"title": "资源标题",
"resource_type": "Drive 恢复命令需要的资源类型",
"original_token": "原始 Drive token",
"original_node_token": "原始 Wiki node token",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径",
"planned_target_parent_token": "计划目标父级 token",
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因"
}
```
| 字段 | 说明 |
|-------|------|
| `snapshot_id` | 稳定快照行 ID。 |
| `plan_id` | 对应 `MovePlanItem.plan_id`,用于连接计划、快照和执行日志。 |
| `resource_id` | 对应稳定资源 ID用于审计计划来源。 |
| `resource_type` | `drive +move` 恢复时必须传入的 `--type`;非 Drive 恢复也保留原始资源类型。 |
| `original_token` / `original_node_token` | 执行前源资源身份。 |
| `original_parent_kind` / `original_parent_token` | 执行前父级位置。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因。 |
## 执行日志
每次写操作尝试都必须追加一条内部日志:
```json
{
"journal_id": "稳定日志行 ID",
"plan_id": "对应 MovePlanItem 的 plan_id",
"time": "ISO-8601",
"action_type": "create_target|move_resource|restore_resource|cleanup_target",
"operation": "create_folder|create_node|move_drive|move_wiki_node|move_wiki_to_drive|restore_drive|restore_wiki_node|delete_folder|delete_wiki_node",
"command_family": "drive +move|wiki +move|wiki +move-to-drive|drive +create-folder|wiki +node-create|drive +delete|wiki +node-delete",
"resolved_command_args": {"<arg>": "实际发送的参数"},
"title": "资源或目标名称",
"resource_type": "资源类型",
"input_token": "命令输入 token",
"input_node_token": "命令输入 Wiki node token",
"input_parent_token": "已知源父级 token",
"target_parent_token": "目标父级 token",
"returned_token": "命令返回 token",
"returned_node_token": "命令返回 Wiki node token",
"returned_parent_token": "返回父级 token",
"task_id": "异步任务 ID",
"next_command": "异步继续命令",
"created_by_workflow": "是否由本次 workflow 创建",
"rollback_eligible": "是否可进入自动恢复计划",
"status": "success|failed|pending",
"error": "失败原因"
}
```
字段说明:
| 字段 | 说明 |
|------|------|
| `journal_id` | 稳定日志行 ID。 |
| `plan_id` | 对应 `MovePlanItem`,用于把日志项匹配回原计划。 |
| `operation` | 细分操作类型,用于区分创建、移动和恢复。 |
| `resolved_command_args` | 从确认计划解析出的实际发送参数;用于审计 `created_by_plan:<plan_id>` 的唯一运行时替换。 |
| `resource_type` | 实际移动 / 恢复使用的资源类型。 |
| `input_token` / `input_node_token` | 命令实际输入的资源 token。 |
| `input_parent_token` | 执行前已知源父级 token。 |
| `target_parent_token` | 命令输入的目标父级 token。 |
| `returned_token` / `returned_node_token` | 命令返回的资源 token恢复时作为当前源。 |
| `returned_parent_token` | 命令返回的当前父级 token。 |
| `task_id` / `next_command` | 异步任务跟踪信息。 |
| `created_by_workflow` | 是否由本次 workflow 创建,用于后续清理判断。 |
| `rollback_eligible` | 是否可进入自动恢复计划。 |
| `status` | 写操作状态,异步未完成时为 `pending`。 |
除非用户要求查看技术调试细节,否则不要展示完整原始命令输出。

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流:召回
由状态 `SEARCH_RECALL``RECALL_ENHANCE` 加载。
本文档负责基础搜索召回、覆盖增强、query 证据、去重和 `CandidateItem`。不得解析目标移动 token、读取完整文档内容、判断相关性或执行写操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-search.md`](lark-drive-search.md) 处理 `drive +search` 语法、过滤条件、单批最多 5 页和身份语义;本 workflow 的全量续批规则见下文。
## 搜索原则
1. 默认使用 `drive +search --mine` 召回当前用户 owner / 负责的 Workspace 资源。
2. 除非用户本来就要求限定范围,否则不要要求用户指定文件夹或 Wiki 范围。
3. `SEARCH_RECALL``RECALL_ENHANCE` 必须保持为独立状态。
4. `SEARCH_RECALL` 使用用户原始关键词、`owner_scope` 和显式限制。
5. `RECALL_ENHANCE` 可以基于基础召回证据增加扩展 query且必须继承同一个 `owner_scope`
6. 每个候选项必须保留 query 证据,方便后续解释来源。
7. 单页或单个最多 5 页的 query 批次不代表完整覆盖;`has_more=true` 时必须保存 `next_page_token` 并自动开始下一批,直到 `has_more=false` 或出现阻塞。
8. 召回和增强召回可能耗时较长,执行超过 60 秒时必须输出进度提示,之后约每 60 秒提示一次。
9. 只有用户在 `CONFIRM_CONTEXT` 明确确认 `owner_scope=all_visible` 时,才允许移除 `--mine`
### 分页优先级与完成语义
1. 用户确认进入 `topic_move_collector` 即表示同意为本次收集任务执行完整召回;无需再要求用户额外说“全部 / 全量 / 继续翻”。本规则覆盖 `lark-drive-search.md` 的默认首屏交互规则。
2. 仍遵守 `lark-drive-search.md` 的单轮最多 5 页限制。每读取最多 5 页形成一个批次;批次结束且 `has_more=true` 时,保存 checkpoint并使用原 query、原过滤条件和返回的 `next_page_token` 自动开始下一批。
3. 自动续批不改变 workflow 状态,也不触发用户确认。执行超过约 60 秒时只输出进度。
4. 一个 query 只有在 `has_more=false` 时才是 `complete`。单批结束、达到 5 页或已有部分候选都不代表完成。
5. 当前状态的全部 query 都为 `complete` 后,才能进入下一状态。认证、权限、无效分页 token、连续重试失败或工具预算不足属于 blocker必须保留 checkpoint、报告部分召回并停在当前状态不得把部分结果当成完整召回继续分类。
### QueryRecallState
每个基础 / 增强 query 必须维护:
```json
{
"query_id": "稳定 query ID",
"query": "完整 query",
"recall_stage": "search_recall|recall_enhance",
"page_count": 0,
"batch_count": 0,
"next_page_token": "下一批起点",
"has_more": true,
"status": "pending|running|complete|blocked",
"blocker": "阻塞原因"
}
```
## 状态:`SEARCH_RECALL`
进入条件:用户已确认 `CONFIRM_CONTEXT`
必须:
1. 基于已确认的 `topic` 构造基础 query。
2. 应用默认 `owner_scope=mine``constraints` 中的显式限制。
3. 不隐式添加 `--folder-tokens``--space-ids`
4.`owner_scope=mine` 时,所有基础 query 必须带 `--mine`
5.`owner_scope=all_visible` 时,不带 `--mine`,并记录扩展召回风险。
6. 除非命令限制要求更低值,否则使用 `--page-size 20`
7. 每个基础 query 按每批最多 5 页执行;批次结束仍有更多结果时自动续批,并合并所有页面。
8. 记录基础统计query、搜索范围、页数、批次数、收集数量、重复数量、阻塞项。
9. 只有全部基础 query 的 `status=complete``has_more=false` 时,才进入 `RECALL_ENHANCE`;出现阻塞时保持在 `SEARCH_RECALL`
### 召回进度 UI
`SEARCH_RECALL``RECALL_ENHANCE` 持续超过约 60 秒时,输出当前进度:
```text
搜索进度:当前阶段 <SEARCH_RECALL|RECALL_ENHANCE>,已执行 <query_count> 个 query已读取 <page_count> 页,收集候选 <raw_count> 项,去重后 <unique_count> 项。继续搜索,不会创建或移动资源。
```
如果正在执行具体 query可补充
```text
当前 query<query>
```
### 基础 Query 规则
| 用户输入 | 基础 Query |
|------------|----------------|
| 单个关键词 | 直接作为 `--query`。 |
| 多个关键词组成一个短语 | 优先按用户输入的短语执行。 |
| 明确精确短语 | 保留引号。 |
| 明确排除词 | 保留负向词。 |
| 没有真实关键词,只有过滤条件 | 使用 `--query ""` 搭配过滤条件。 |
`SEARCH_RECALL` 中不得添加同义词、仅标题搜索、仅评论搜索或 OR 扩展。
### 基础召回输出
```text
基础召回完成:
- 使用 query
- 搜索范围:
- 应用限制:
- 收集候选:
- 去重后候选:
- 阻塞项:
下一步:继续执行覆盖增强,不需要你操作;不会创建或移动资源。
```
## 状态:`RECALL_ENHANCE`
进入条件:基础召回完成。
必须:
1. 基于已确认主题和基础召回证据生成增强 query。
2. 确保增强 query 可解释且不引入明显污染。
3. 每个增强 query 都必须继承 `owner_scope``owner_scope=mine` 时必须带 `--mine`
4. 每个 query 都必须按每批最多 5 页处理分页,并自动续批直到 `has_more=false`
5. 有稳定去重键时,按稳定去重键合并候选项。
6. 为每个候选项保留 `source_queries` 和命中证据。
7. 当 query 不再产生新候选,或出现工具预算 / API 阻塞时,停止增强。
### 召回阶段退出门禁
`RECALL_ENHANCE` 完成后,必须:
1. 确认全部基础和增强 query 的 `status=complete``has_more=false`,再固化完整 `candidate_items`,包含去重结果、`source_queries``match_channels``snippets``dedupe_status`
2.`current_state` 设置为 `RESOURCE_RESOLVE`
3. 加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md)。
4. 把完整 `candidate_items` 交给 `RESOURCE_RESOLVE`
5. 不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE` 或展示相关性结果。
6. 不得用搜索标题、摘要或 query 命中直接生成高 / 中 / 低相关分组。
### 增强策略
| 策略 | 说明 |
|----------|------|
| 精确短语 | 对明确短语使用 `"..."` 提高精确命中。 |
| `intitle:` | 对项目名、客户名、制度名、报表名等标题特征强的主题执行标题召回。 |
| `--only-title` | 当标题命中更可信时使用。 |
| `--only-comment` | 当主题可能只出现在评论讨论中时使用。 |
| 类型拆分 | 对 `docx``sheet``bitable``slides``file` 等分类型搜索,减少服务端排序偏差。 |
| 同义词 / 别名 | 使用业务上明确的同义词、简称、英文名、中文名。 |
| OR 扩展 | 对同一实体的别名做 OR 扩展。 |
| 负向词 | 对明显噪声使用 `-term`,但不能排除可能相关的主题词。 |
### Query 证据
每个候选项都要记录:
| 字段 | 说明 |
|-------|------|
| `source_queries` | 命中过该资源的 query 列表。 |
| `match_channels` | 命中位置,如 title、body、comment、metadata。 |
| `snippets` | 搜索返回的摘要或片段。 |
| `query_rank` | 资源在各 query 中的相对位置。 |
| `recall_stage` | `search_recall``recall_enhance`。 |
## 去重规则
必须:
1. 搜索响应提供 canonical token 时,优先使用 canonical token。
2. 对 Wiki 结果,不得只按 object token 去重;同一对象可能出现在多个 Wiki 节点中。
3. token 缺失时,使用 URL 作为 fallback。
4. 合并重复项时保留所有 query 证据。
5. 如果无法确定去重是否稳定,保留该项并设置 `dedupe_status=uncertain`
## CandidateItem
```json
{
"title": "资源标题",
"url": "资源链接",
"raw_type": "搜索返回类型",
"source_queries": ["query"],
"match_channels": ["title|body|comment|metadata"],
"snippets": ["命中片段"],
"page_rank": 1,
"dedupe_key": "候选去重键",
"dedupe_status": "stable|fallback|uncertain",
"recall_stage": "search_recall|recall_enhance"
}
```
| 字段 | 说明 |
|-------|------|
| `title` | 搜索结果标题。 |
| `url` | 资源访问链接。 |
| `raw_type` | 搜索返回的原始类型。 |
| `source_queries` | 命中过该资源的搜索 query。 |
| `match_channels` | 命中位置。 |
| `snippets` | 摘要或命中片段。 |
| `page_rank` | 当前 query 下的排序位置。 |
| `dedupe_key` | 候选去重键。 |
| `dedupe_status` | 去重可信度。 |
| `recall_stage` | 资源首次进入候选集的召回阶段。 |
## 阻塞项
缺少认证 / scope、`drive +search` 返回权限或策略阻塞、分页 token 无效、分页重试后仍无法继续,或工具预算不足以完成全部页面时,必须把对应 `QueryRecallState.status` 设置为 `blocked`,保留累计候选、页数和 `next_page_token`,停止并报告。阻塞解除后从 checkpoint 续跑;在全部 query 完成前不得进入资源解析或分类阶段。

View File

@@ -0,0 +1,231 @@
# 主题资料收集工作流:资源解析与内容验证
由状态 `RESOURCE_RESOLVE``CONTENT_VERIFY` 加载。
本文档负责资源解析、结构化父级、移动资格、内容验证和 `ResourceItem`。不得判断相关性、生成移动计划、创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 按 [`lark-drive-inspect.md`](lark-drive-inspect.md) 处理 URL / token 解析。
3. 使用 `drive metas batch_query` 补齐 Drive 资源 owner、标题和 URL。
4. 必要时使用 `drive permission.members auth` 读取权限信号;该接口不提供 `full_access` / 移动权限的直接判定,不能把 `manage_public` 等同为可移动。
5. 按 [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 处理 Wiki 节点解析。
6. 按 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md) 读取文档内容。
7. 需要验证 Sheet 内容时,按 [`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md) 执行。
## 进入解析与验证阶段前校验
进入本文档后,如果 `resource_items` 还不存在,当前状态必须是 `RESOURCE_RESOLVE`
禁止从 `candidate_items` 直接进入 `CONTENT_VERIFY``RELEVANCE_CLASSIFY`,也禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使候选项已有标题、URL、摘要或 token也必须依次执行 `RESOURCE_RESOLVE``CONTENT_VERIFY`;两个状态不得合并。
## 状态:`RESOURCE_RESOLVE`
进入条件:候选列表已准备。
必须:
1. 为每个 `CandidateItem` 生成稳定 `resource_id`,并转换为标准化 `ResourceItem`
2. 解析 canonical token、资源类型、URL、结构化当前父级、Wiki 节点身份和读取权限状态。
3. 对 Wiki 资源同时保留 `wiki_node_token``wiki_obj_token`
4.`move_method` 补齐 `owner_id``is_owner``source_move_state``source_parent_write_state``target_write_state``move_permission_state``move_permission_basis`
5. 基于 `target_location` 检测不支持的移动方向。
6. 未解析成功的资源仍保留在审核分组中,不得静默丢弃。
7. 即使搜索结果已经包含标题、URL 或 token也必须经过本状态生成 `ResourceItem`;不得从召回结果直接进入相关性分级。
8. 只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源,才能进入后续默认移动链路。
9. 解析耗时超过约 60 秒时,必须输出进度提示,之后约每 60 秒提示一次。
### 解析规则
| 候选类型 | agent 必须执行 |
|----------------|---------------|
| Drive URL / token | token 或类型不确定时,使用 `drive +inspect`。 |
| Wiki URL / token | 使用 `drive +inspect``wiki +node-get`;保留节点身份和对象身份。 |
| 文件夹候选 | 标记为容器;不要当作普通文档做内容验证。 |
| 快捷方式候选 | 能解析源资源时解析源资源;同时保留快捷方式身份。 |
| 无读取权限 | 保留可见元数据,并设置 `permission_state=denied`。 |
| 无移动权限或移动权限未知 | 保留可见元数据和召回证据,并设置对应 `move_permission_state`。 |
| 无法解析当前父级 | 设置 `current_parent_kind=unknown`,保留已知路径,后续计划项设置 `rollback_supported=false` 和明确 blocker不得编造父级 token。 |
### 资源解析进度 UI
`RESOURCE_RESOLVE` 持续超过约 60 秒时,输出当前进度:
```text
资源解析进度:已解析 <resolved_count>/<total_count> 项,已确认可移动 <movable_count> 项,无移动权限 <denied_count> 项,移动权限未知 <unknown_count> 项,解析失败 <failed_count> 项。
当前资源:<title>
继续解析中,不会创建或移动资源。
```
如果正在处理权限或 owner 元数据,可补充:
```text
当前步骤:解析 owner / 当前父级 / 移动资格。
```
`RESOURCE_RESOLVE` 完成后,输出摘要:
```text
资源解析完成:
- 候选总数N 项
- 可进入内容验证N 项
- 无移动权限N 项
- 移动权限未知N 项
- 解析失败或无读取权限N 项
下一步会对可移动资源做内容验证;不会创建或移动资源。
```
### 资源解析出口门禁
`RESOURCE_RESOLVE` 完成后必须:
1.`content_verify_completed` 重置为 `false`
2. 将下一状态设置为 `CONTENT_VERIFY`,不得设置为 `RELEVANCE_CLASSIFY``PLAN_MOVE`
3. 不得在本状态生成 `relevance``relevance_groups` 或移动计划。
4. 即使可读取正文的资源数量为 0也必须进入 `CONTENT_VERIFY`,为每项记录跳过验证原因并输出验证摘要。
### 移动资格判定
`owner` 只能作为部分权限证据,不得单独把资源判为 `movable``RESOURCE_RESOLVE` 必须先按 `move_method` 记录以下独立状态:
| 字段 | 说明 |
|------|------|
| `source_move_state` | 当前身份是否确认可以对源资源执行对应移动Drive owner 只可作为 Drive 源资源可管理的证据Wiki 底层资源 owner 不能证明 Wiki 节点可移动。 |
| `source_parent_write_state` | 当前身份是否确认可编辑源位置;仅 `drive_move` 必须确认,其他移动方式为 `not_required`。 |
| `target_write_state` | 当前身份是否确认可写目标位置;待创建目标以父级位置的创建 / 写入权限为准。 |
#### 按移动方式的权限矩阵
| `move_method` | `source_move_state=confirmed` 的证据 | `source_parent_write_state` | `target_write_state` |
|---------------|--------------------------------------|-----------------------------|----------------------|
| `drive_move` | 当前用户是可靠解析出的 Drive 资源 owner或有明确资源可管理证据 | 必须为 `confirmed` | 必须为 `confirmed` |
| `wiki_move_docs_to_wiki` | 有明确的 Drive 文档直接迁入权限;仅 owner 元数据不足以证明可直接迁入 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_node` | 有明确的 Wiki 节点 / 源空间移动权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Wiki 节点 / 空间可写 |
| `wiki_move_to_drive` | 有明确的 Wiki 节点移出权限;不得从底层资源 owner 推导 | `not_required` | 必须确认目标 Drive 文件夹可写 |
#### 聚合顺序
1. 目标方向或资源类型不支持时,设置 `move_permission_state=denied``move_permission_basis=["unsupported_direction"]`
2. 任一必需状态为 `denied` 时,设置 `move_permission_state=denied`,并在 `move_permission_basis` 记录 `source_denied``source_parent_denied``target_denied`
3. 任一必需状态为 `unknown` 时,设置 `move_permission_state=unknown`,并记录对应的 `source_unknown``source_parent_unknown``target_unknown`
4. 只有权限矩阵中的全部必需状态都为 `confirmed` 时,才能设置 `move_permission_state=movable``move_permission_basis=["permission_matrix_confirmed"]`
注意:
1. `drive permission.members auth` 不提供 `full_access``move` action不能用 `view``edit``share``manage_public` 结果推断源位置或目标位置可写。
2. `target_write_state=unknown|denied` 的资源不得进入高 / 中相关可执行分组或移动计划。
3. `move_permission_state=unknown` 的资源默认不进入内容验证、相关性高 / 中分组或移动计划。
4.`owner_scope=mine` 但解析出的 owner 不是当前用户时,将该资源视为异常候选,设置 `source_move_state=unknown``move_permission_state=unknown`,不得加入移动计划。
## 状态:`CONTENT_VERIFY`
进入条件:资源列表已准备。
必须:
1. 本状态不可跳过,也不得与 `RESOURCE_RESOLVE``RELEVANCE_CLASSIFY` 合并;没有可读取正文的资源时仍须执行。
2. 只在资源解析后读取支持的内容。
3. 按数量、大小和类型能力限制读取范围。
4. 结合搜索证据和内容证据;除非标题精确且足够强,否则不要仅凭标题判为高相关。
5. 将不可读取资源标记为 `unverifiable``permission_denied`
6. 不得自动申请权限。
7. 为每个资源写入验证状态:已读取内容证据、仅可使用搜索证据、无权限、无移动权限、移动权限未知、无法验证或不支持内容验证。
8.`move_permission_state=denied|unknown` 的资源,不再读取正文内容,写入跳过验证原因并保留召回证据;写入跳过原因属于执行本状态,不等于跳过本状态。
9. 所有资源都有验证状态或跳过原因后,将 `content_verify_completed` 设置为 `true` 并输出验证摘要。
10. `content_verify_completed=true` 前不得进入 `RELEVANCE_CLASSIFY`
### 验证方式
| 资源类型 | 验证方式 |
|---------------|---------------------|
| `docx` / `doc` | 允许时使用 `docs +fetch --api-version v2`。 |
| `sheet` | 使用 `sheets +find` 查关键词证据,或用 `sheets +read` 读取有界范围。 |
| `bitable` | 只有必要且已加载 Base 能力时验证。 |
| `slides` | 除非具备幻灯片读取能力,否则使用元数据 / 预览 / 标题证据。 |
| `file` | 仅在支持时使用标题、元数据、预览或导出文本。 |
| `wiki` 节点 | 按 `obj_type` 验证底层对象;节点本身不是内容 token。 |
| `folder` | 除非用户明确要移动容器,否则通常不作为主题证据移动。 |
### 内容验证完成 UI
完成 `CONTENT_VERIFY` 后必须输出:
```text
内容验证完成:
- 已读取内容证据N 项
- 仅复用搜索证据N 项
- 因无权限或移动资格跳过N 项
- 无法验证或不支持验证N 项
下一步会基于以上证据进行相关性分组;不会创建或移动资源。
```
如果没有任何资源可以读取正文,仍须输出该摘要,并明确说明所有资源采用的搜索证据或跳过原因。
### 内容验证出口门禁
`CONTENT_VERIFY` 完成后必须:
1. 确认 `content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过原因。
2. 将下一状态设置为 `RELEVANCE_CLASSIFY`
3. 加载 [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md)。
4. 不得直接进入 `PLAN_MOVE`
## ResourceItem
```json
{
"resource_id": "稳定资源 ID",
"title": "资源标题",
"resource_type": "doc|docx|sheet|bitable|file|folder|wiki|slides|shortcut",
"url": "资源链接",
"canonical_token": "标准资源 token",
"wiki_node_token": "Wiki 节点 token",
"wiki_obj_token": "Wiki 底层对象 token",
"wiki_obj_type": "Wiki 底层对象类型",
"space_id": "知识空间 ID",
"current_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"current_parent_token": "当前父级 token",
"current_parent_space_id": "当前父级 Wiki space_id",
"current_path": "用于展示的当前位置",
"owner_id": "资源 owner open_id",
"is_owner": "true|false|unknown",
"permission_state": "readable|denied|unknown",
"source_move_state": "confirmed|unknown|denied",
"source_parent_write_state": "confirmed|unknown|denied|not_required",
"move_permission_state": "movable|denied|unknown",
"move_permission_basis": ["权限矩阵证据或阻塞原因"],
"target_write_state": "confirmed|unknown|denied",
"item_resolve_status": "resolved|partial|failed",
"content_verify_state": "verified|search_evidence_only|skipped_by_move_permission|permission_denied|unverifiable|unsupported",
"content_evidence": ["证据"],
"relevance": "high|medium|low|permission_denied|no_move_permission|move_permission_unknown|unverifiable|unsupported_move_target"
}
```
| 字段 | 说明 |
|-------|------|
| `canonical_token` | 内容读取、Drive 对象操作或底层对象操作使用的标准 tokenWiki 节点移动不得使用该字段。 |
| `resource_id` | 资源解析时生成的稳定 ID用于连接 `ResourceItem``MovePlanItem`。 |
| `wiki_node_token` | Wiki 节点身份,用于 Wiki 节点移动。 |
| `wiki_obj_token` | Wiki 节点背后的真实文档 token。 |
| `current_parent_kind` / `current_parent_token` / `current_parent_space_id` | 结构化执行前父级,用于 `already_at_target` 判断和恢复;未知值不得猜测。 |
| `current_path` | 仅用于用户展示的当前位置,不得代替父级 token。 |
| `owner_id` | 资源 ownerDrive 资源优先来自 `drive metas batch_query`Wiki 节点优先来自 `wiki +node-get`。 |
| `is_owner` | 当前用户是否为资源 owner。 |
| `permission_state` | 当前身份下的读取权限状态。 |
| `source_move_state` | 当前身份是否确认能对源资源执行所选 `move_method`;必须按权限矩阵判断。 |
| `source_parent_write_state` | Drive 内移动所需的源位置编辑状态;非 `drive_move``not_required`。 |
| `move_permission_state` | 权限矩阵聚合结果;只有 `movable` 且目标写入状态为 `confirmed` 才可进入默认移动链路。 |
| `move_permission_basis` | 移动资格判断依据,用于解释为什么纳入或排除。 |
| `target_write_state` | 目标位置是否确认可写。 |
| `item_resolve_status` | 资源项解析状态;不要和 `TargetLocation.target_resolve_status` 混用。 |
| `content_verify_state` | 内容验证状态或跳过验证原因。 |
| `content_evidence` | 支撑相关性判断的命中证据。 |
| `relevance` | 相关性和可执行性分组。 |

View File

@@ -0,0 +1,248 @@
# 主题资料收集工作流:审核与计划
由状态 `RELEVANCE_CLASSIFY``PLAN_MOVE` 加载。
本文档负责相关性分级、审核 UI、移动计划生成和 `MovePlanItem`。不得重新执行资源解析或内容验证,也不得创建目标、移动资源或执行恢复操作。
本文档只服务 `topic_move_collector`。进入本文档时,`workflow_id` 必须是 `topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 输入契约
进入本文档前必须已有:
1. `resource_items`,且每个 `ResourceItem` 已包含稳定 `resource_id`、资源类型、移动所需 token、结构化当前父级、权限状态、内容验证状态和证据。
2. `content_verify_completed=true`
3. 每个资源都有内容证据、搜索证据复用说明或明确跳过原因。
`ResourceItem` schema 和字段生成规则由 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 负责。只要上述输入契约完整,本状态不得为重复读取 schema 而重新加载或执行前一阶段文档。
如果输入字段缺失、资源需要重新解析或用户要求重新读取证据,废弃受影响的相关性和计划结果,返回 `RESOURCE_RESOLVE``CONTENT_VERIFY`,并加载资源解析与内容验证文档;不得在本状态补猜。
## 状态:`RELEVANCE_CLASSIFY`
进入条件:`CONTENT_VERIFY` 已完成,`content_verify_completed=true`,且每个 `ResourceItem` 都已有验证状态或跳过验证原因。
禁止条件:
1. 只有 `candidate_items`,没有 `resource_items`
2. 资源未经过 `RESOURCE_RESOLVE`
3. 资源没有 `RESOURCE_RESOLVE` 写入的移动资格状态。
4. 资源没有 `CONTENT_VERIFY` 写入的验证状态或跳过验证原因。
5. 上一完成状态是 `RESOURCE_RESOLVE`,或 `content_verify_completed` 不为 `true`
必须将每个资源归入且只归入一个分组:
| 分组 | 说明 | 默认移动 |
|-------|------|--------------|
| `high` | 可移动资源,且主题或内容直接命中,有明确标题 / 正文 / 表格 / 评论证据。 | 是 |
| `medium` | 可移动资源,可能相关,但证据不足或只命中弱相关片段。 | 否,需用户选择 |
| `low` | 可移动资源,弱相关或噪声,保留展示但不建议移动。 | 否 |
| `permission_denied` | 当前身份无权读取或解析,不能验证内容。 | 否 |
| `no_move_permission` | 已确认当前身份不具备移动资格。 | 否 |
| `move_permission_unknown` | 无法确认当前身份是否具备移动资格。 | 否 |
| `unverifiable` | 类型或工具限制导致无法验证内容。 | 否 |
| `unsupported_move_target` | 目标方向或资源类型不支持移动。 | 否 |
`high``medium``low` 只能包含 `move_permission_state=movable``target_write_state=confirmed` 的资源。
判为高相关至少需要一个强证据:
1. 标题或内容中出现精确主题短语。
2. 多个主题词在相关上下文中同时出现。
3. Sheet / 表格单元格明确匹配用户主题。
4. 用户明确提供的文档名或项目别名命中。
中相关示例:
1. 标题包含一个主题词,但内容无法确认。
2. 搜索摘要看起来相关,但无法完整读取。
3. 别名命中合理但证据不够强。
## 审核 UI
必须展示每个分组中的资源名称。
默认展示规则:
1. 展开 `high``medium`
2. 折叠 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`,但展示数量并允许展开。
3. 每个可见资源展示标题、类型、当前位置、证据和默认动作。
4. 除非用户要求技术细节,否则不展示原始 token。
示例:
```text
筛选结果:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关(默认移动):
- 标题|类型|证据|当前位置
中相关(需你勾选后才移动):
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
你可以选择:
1. 确认按默认规则生成移动计划。
2. 勾选要加入计划的中相关资源。
3. 要求把某些资源移到其他分组或从计划中移除。
4. 展开低相关 / 无权限 / 无移动权限 / 移动权限未知 / 无法验证 / 不支持移动分组查看名称。
```
### 用户调整规则
如果用户不同意相关性结果,必须基于用户要求更新 `relevance_groups`,再重新展示分组结果并重新生成后续移动计划。
典型调整包括:
1.`high` 中移除某个资源。
2.`medium` 中某个资源提升为 `high`
3. 将某个资源标为 `low` 或不移动。
4. 要求重新读取证据或重新判断一批资源。
5. 要求重新确认某些资源的移动权限。
用户调整后:
1. 旧的 `move_plan_items` 立即失效。
2. 必须先输出“调整后相关性结果”,展示被调整项、各分组数量和高 / 中相关资源名称。
3. 不得只回复“已调整”,也不得直接跳到 `CONFIRM_EXECUTION`
4. 必须基于新的 `relevance_groups` 重新执行 `PLAN_MOVE`
5. 不得把 `no_move_permission``move_permission_unknown` 资源直接提升到 `high` / `medium`;必须先回到 `RESOURCE_RESOLVE`,加载 [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) 取得可移动证据。
### 调整后结果 UI
```text
已按你的要求调整相关性结果:
- <标题><原分组> -> <新分组>
调整后分组:
搜索范围:<当前用户 owner / 负责的资源 | 所有当前身份可见资源>
高相关默认移动N 项
- 标题|类型|证据|当前位置
中相关需你勾选后才移动N 项
- 标题|类型|证据|当前位置
未默认移动:
- 低相关N 项
- 无权限N 项
- 无移动权限N 项
- 移动权限未知N 项
- 无法验证N 项
- 不支持移动N 项
接下来会基于这个调整后的结果重新生成移动计划;你也可以继续调整。
```
## 状态:`PLAN_MOVE`
进入条件:相关性分组已准备。
必须:
1.`target_location.create_required=true` 时,纳入目标创建计划。
2. 生成移动计划前,比较规范化的当前父级与目标父级;已在目标位置的资源生成 `skip_resource`,设置 `skip_reason=already_at_target`,不得生成移动命令。
3. 默认纳入全部 `high``move_permission_state=movable``target_write_state=confirmed` 的资源。
4. 只有用户明确选择时,才纳入 `medium``move_permission_state=movable``target_write_state=confirmed` 的资源。
5. 默认排除 `low``permission_denied``no_move_permission``move_permission_unknown``unverifiable``unsupported_move_target`
6. 为每个跳过项生成 `skip_reason`
7. 为每个计划项生成稳定 `plan_id`,并使用 `resource_id` 连接对应资源;不得按标题或临时 token 猜测关联。
8.`command_family` 保存完整、不可变的 `command_args`;不得把 Wiki 底层对象 token 当作 Wiki 节点移动 token。
9. 为每个 `move_resource` 项复制执行前恢复所需的完整 `rollback_input`,使确认计划不依赖运行时回查 `ResourceItem`
10. 当前父级无法结构化解析或属于 Drive / Wiki 跨容器移动时,设置 `rollback_supported=false` 和明确 `rollback_blocker`;该单项仍可进入确认,但必须逐项展示不可恢复风险,不得阻塞其他独立项。
11. 停止并等待用户选择或执行意图。
12. 不得为 `move_permission_state!=movable``target_write_state!=confirmed` 的资源生成 `move_resource` 计划项。
### 已在目标位置判定
1. `drive_move` 比较 `current_parent_kind` 和目标 Drive 父级,并比较规范化后的 `current_parent_token` / root 标识。
2. `wiki_move_node` 比较 `current_parent_space_id``current_parent_kind``current_parent_token`Wiki 空间根节点使用明确的 root 标识,不得用空字符串和未知状态混淆。
3. 只有父级类型、space ID适用时和 token 都已解析且相等时,才能设置 `skip_reason=already_at_target`;父级未知时不得猜测为相等。
### 移动 token 选择
| `command_family` | `command_args` 必须包含 |
|------------------|---------------------------|
| `drive +move` | `file_token``type``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空且目标类型为 root。 |
| `wiki +move`node | `node_token`,以及 `target_space_id``target_parent_token`;可选 `source_space_id`。不得使用 `wiki_obj_token` 代替 `node_token`。 |
| `wiki +move`docs-to-wiki | `obj_type``obj_token``target_space_id`、可选 `target_parent_token`,并显式保存 `apply=false`。 |
| `wiki +move-to-drive` | `node_token``folder_token`;移动到 Drive root 时显式记录 `folder_token` 为空。 |
| `drive +create-folder` | `name`、父级 `folder_token`;创建在 Drive root 时显式记录父级为空。 |
| `wiki +node-create` | `space_id``title``obj_type`、可选 `parent_node_token`。 |
| `none` | 不执行命令,保留 `skip_reason`。 |
目标由本次 workflow 创建时,对应目标参数保存 `created_by_plan:<create_target plan_id>` 引用。`EXECUTE` 只允许把该引用替换为对应创建计划返回的 token不得重新搜索或猜测目标。
### 计划 UI
```text
移动计划已生成:
- 默认将移动高相关N 项
- 你已选择中相关N 项
- 其中不可自动恢复N 项
- 已在目标位置N 项
- 不会移动N 项
- 无移动权限N 项
- 移动权限未知N 项
你可以回复“确认执行”,也可以继续调整分组、增减中相关资源,或取消本次移动。
```
## MovePlanItem
```json
{
"plan_id": "稳定计划项 ID",
"resource_id": "对应 ResourceItem.resource_idcreate_target 为空",
"action_type": "create_target|move_resource|skip_resource|unsupported",
"title": "资源或目标名称",
"resource_type": "源资源类型",
"move_method": "drive_move|wiki_move_node|wiki_move_docs_to_wiki|wiki_move_to_drive|none",
"command_family": "具体 shortcut 命令或 none",
"command_args": {
"<arg>": "按 command_family 参数表保存的完整、类型明确的参数"
},
"source_path": "用户确认时展示的源位置",
"target_path": "用户确认时展示的目标位置",
"move_permission_state": "movable|denied|unknown|not_required",
"target_write_state": "confirmed|unknown|denied",
"reason": "纳入或跳过原因",
"skip_reason": "already_at_target 或其他跳过原因",
"rollback_input": {
"source_kind": "drive|wiki",
"original_token": "原始 Drive / obj token",
"original_node_token": "原始 Wiki node token",
"resource_type": "恢复命令需要的资源类型",
"original_parent_kind": "drive_folder|drive_root|wiki_node|wiki_space_root|unknown",
"original_parent_token": "原始父级 token",
"original_space_id": "原始 Wiki space_id",
"original_path": "执行前路径"
},
"rollback_supported": "是否支持自动恢复",
"rollback_blocker": "不可自动恢复原因",
"execution_status": "pending|success|failed|skipped"
}
```
| 字段 | 说明 |
|-------|------|
| `plan_id` | 稳定计划项 ID用于连接计划、快照和执行日志。 |
| `resource_id` | 稳定资源 ID用于连接确认计划和解析结果`create_target` 为空。执行阶段不得依赖该关联回查可变参数。 |
| `action_type` | 计划动作类型。 |
| `move_method` | 实际使用的移动方式。 |
| `command_family` / `command_args` | 用户确认的完整写命令及参数快照;确认后保持不可变。目标待创建时只允许使用 `created_by_plan:<plan_id>` 引用。 |
| `move_permission_state` / `target_write_state` | 用户确认时的权限门禁快照;`move_resource` 必须分别为 `movable` / `confirmed``create_target` 的移动权限为 `not_required`,但父级写入权限仍必须为 `confirmed`。 |
| `rollback_input` | 从 `ResourceItem` 复制出的完整恢复输入;仅 `move_resource` 必填,生成确认计划后不得再回查或猜测。 |
| `rollback_supported` | 是否支持自动恢复。 |
| `rollback_blocker` | 不可自动恢复原因;跨容器移动使用 `cross_container_permission_model_not_losslessly_restorable`,原父级 token 缺失使用 `original_parent_token_unavailable`。 |
| `execution_status` | 执行状态。 |

View File

@@ -0,0 +1,174 @@
# 主题资料收集工作流:输入与目标确认
由状态 `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT` 加载。
本文档负责用户输入解析、目标位置解析、搜索前确认和 `TargetLocation`。不得执行搜索召回、资源分类、目标创建或资源移动。
本文档只服务 `topic_move_collector`。进入本文档后必须确认 `workflow_id=topic_move_collector`;不得把当前任务改路由到其他 workflow。
## 必读上下文
执行本文档规则前:
1. 按 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 处理身份、认证和权限。
2. 解析 Drive 目标时,遵循 [`lark-drive-inspect.md`](lark-drive-inspect.md)、[`lark-drive-create-folder.md`](lark-drive-create-folder.md) 和 [`lark-drive-search.md`](lark-drive-search.md)。
3. 解析 Wiki 目标时,遵循 [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)、[`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md) 和 [`../../lark-wiki/references/lark-wiki-node-create.md`](../../lark-wiki/references/lark-wiki-node-create.md)。
## 状态:`PARSE_INPUT`
进入条件workflow 被触发。
必须:
1. 提取 `topic``target``identity``owner_scope``constraints`
2.`topic``target` 视为必填字段。
3. 除非用户明确要求 bot / app 视角,否则 `identity` 默认使用用户身份。
4. 默认 `allow_cross_container_move=true`,但必须在 `CONFIRM_CONTEXT` 展示。
5. 默认 `owner_scope=mine`,表示只搜索当前用户 owner / 负责的资源。
6. 只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才设置 `owner_scope=all_visible`
7. 除非用户明确提供限制,否则 `constraints` 保持为空。
8. 如果缺少 `topic``target`,只提出最小澄清问题。
### 输入字段
| 字段 | 说明 |
|-------|------|
| `topic` | 用户要查找的主题、关键词、内容线索、同义词、缩写、排除词。 |
| `target` | 归档目标,可以是已有 Drive 文件夹、已有 Wiki 节点、待创建 Drive 文件夹或待创建 Wiki 节点。 |
| `identity` | 执行身份,默认 `--as user`。 |
| `owner_scope` | 搜索 owner 范围,默认 `mine``all_visible` 仅在用户明确要求扩展到所有可见资源时使用。 |
| `constraints` | 用户显式给出的类型、时间、创建人、评论、标题、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须确认。 |
### 澄清模板
```text
我还需要补齐两个信息后才能开始:
1. 要查找的主题 / 关键词 / 内容线索是什么?
2. 找到后要移动到哪个 Drive 文件夹或 Wiki 节点?如果需要新建目标,也请说明父级位置和新名称。
```
## 状态:`RESOLVE_TARGET`
进入条件:`topic``target` 已获得。
必须:
1. 将已有目标解析为具体 token。
2. 如果目标需要创建,只解析父级位置和新目标名称。
3. 在本状态中不得创建文件夹或 Wiki 节点。
4. 分别保留 Drive 文件夹 token、Wiki 节点 token、Wiki 对象 token、space ID 和 parent token。
5. 如果目标 URL / token 存在,但当前身份无法读取或解析目标位置,设置 `target_resolve_status=permission_denied`,保持在 `RESOLVE_TARGET` 并等待用户更换目标或结束;不得进入搜索。
6. 如果已知移动方向不支持,尽早标记。
### 目标解析
| 条件 | agent 必须执行 | 设置 `target_type` |
|-----------|---------------|-------------------|
| 已有 Drive 文件夹 URL 或 token | 有 URL 时用 `drive +inspect` 解析;保留 `folder_token` | `drive_folder` |
| 已有 Wiki 节点 URL 或 token | 用 `wiki +node-get``drive +inspect` 解析;保留 `wiki_node_token``space_id` | `wiki_node` |
| 在已知父级下新建 Drive 文件夹 | 解析父文件夹;保存新文件夹名称;不创建 | `new_drive_folder` |
| 在已知父级下新建 Wiki 节点 | 解析知识空间和可选父节点;保存新节点标题;不创建 | `new_wiki_node` |
| 以 Wiki 空间根节点作为目标 | 解析 `space_id`parent token 可以为空 | `wiki_space` |
| 目标名称有歧义 | 仅在必要时搜索或列出候选;展示候选并等待用户选择 | `unknown` |
### 目标解析状态
| 条件 | `target_resolve_status` |
|------|--------------------------|
| 目标已解析,或待创建目标的父级位置已解析 | `resolved` |
| 目标名称有歧义、候选不唯一,或 `target_type=unknown` 需要用户选择 | `ambiguous` |
| 已知目标方向或目标类型不支持本 workflow | `unsupported` |
| 目标 URL / token 存在,但当前身份无权读取、解析或确认目标位置 | `permission_denied` |
### 目标解析出口门禁
| `target_resolve_status` | 下一状态 | agent 必须执行 |
|-------------------------|----------|----------------|
| `resolved` | `CONFIRM_CONTEXT` | 展示已解析目标并进入搜索前确认。 |
| `ambiguous` | 保持 `RESOLVE_TARGET` | 展示候选并等待用户选择;不得进入 `CONFIRM_CONTEXT`。 |
| `unsupported` | 保持 `RESOLVE_TARGET` | 展示不支持原因,等待用户更换目标或结束;不得搜索。 |
| `permission_denied` | 保持 `RESOLVE_TARGET` | 展示权限 blocker等待用户更换目标或结束不得搜索。 |
用户提供新目标后,重新执行 `RESOLVE_TARGET`。只有新的解析结果为 `resolved`,才能进入 `CONFIRM_CONTEXT`;用户选择结束时进入 `DONE`
### 跨容器规则
| 来源 -> 目标 | 默认规则 |
|------------------|---------|
| Drive 资源 -> Drive 文件夹 | 支持,使用 `drive +move`。 |
| Drive 文档类资源 -> Wiki 节点 / 空间 | 资源类型支持时,使用 `wiki +move`。 |
| Wiki 节点 -> Wiki 节点 / 空间 | 支持,使用 `wiki +move --node-token`。 |
| Wiki 节点 -> Drive 文件夹 | `wiki +move-to-drive`。 |
## 状态:`CONFIRM_CONTEXT`
进入条件:`target_resolve_status=resolved`
必须:
1. 展示主题、目标、身份、搜索 owner 范围、限制和目标解析字段。
2. 说明下一步只进行搜索 / 读取。
3. 说明是否计划创建目标,但尚未执行。
4. 展示是否允许跨容器移动。
5. 在进入 `SEARCH_RECALL` 前停止并等待用户确认。
6. 如果 `owner_scope=all_visible`,明确提示候选数量可能较多,且可能包含无法移动的资源。
### 确认 UI
```text
我先确认本次收集任务。
查找主题:
目标位置:
目标解析:
执行身份:
搜索范围:
可选限制:
跨容器移动:
下一步操作:只进行搜索和读取验证,不创建目标,不移动资源。
请确认是否按以上信息开始搜索?
```
默认搜索范围文案:
```text
搜索范围:当前用户 owner / 负责的资源
```
扩展搜索范围文案:
```text
搜索范围:所有当前身份可见资源
风险提示:候选数量可能较多,且部分资源可能无法移动;后续仍会经过资源解析和内容验证。
```
如果用户修改任一字段,更新 `topic``target_location``owner_scope``constraints`,然后只重新执行受影响的 setup 状态,再次展示确认信息。
## TargetLocation
```json
{
"target_type": "drive_folder|wiki_node|wiki_space|new_drive_folder|new_wiki_node|unknown",
"target_token": "已有目标的 folder_token 或 wiki_node_token",
"parent_token": "待创建目标的父级 folder_token 或 wiki_node_token",
"space_id": "知识库空间 ID",
"target_name": "待创建目标名称",
"create_required": false,
"allow_cross_container_move": true,
"target_resolve_status": "resolved|ambiguous|unsupported|permission_denied"
}
```
| 字段 | 说明 |
|-------|------|
| `target_type` | 目标位置类型,用于决定后续创建和移动命令。 |
| `target_token` | 已有目标的可执行 token。 |
| `parent_token` | 待创建目标的父级位置 token。 |
| `space_id` | Wiki 目标所属知识空间 ID。 |
| `target_name` | 待创建目标的名称。 |
| `create_required` | 是否需要在 `EXECUTE` 阶段创建目标。 |
| `allow_cross_container_move` | 是否允许 Drive / Wiki 之间移动。 |
| `target_resolve_status` | 目标位置解析状态;不要和 `ResourceItem.item_resolve_status` 混用。 |

View File

@@ -0,0 +1,202 @@
# 主题资料收集工作流
Workflow id: `topic_move_collector`
Risk / Structure: `R2-R3` / `S3`
本文档实现已注册的主题资料收集 workflow。执行前必须先阅读 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
本文档负责定义本 workflow 的全局约束、状态机和渐进加载关系。具体阶段规则放在配套文档中,只有进入对应状态时才加载。
配套文档只是本 workflow 的引用文件,不是独立 skill。不要把用户请求直接路由到某个配套文档。
## 必读上下文
执行本 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md),用于处理身份、认证、权限和写操作确认规则。
按阶段渐进加载其他 skill / 引用文档:
- 目标是 Wiki 或个人文档库:[`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md)
- 需要读取文档内容:[`../../lark-doc/SKILL.md`](../../lark-doc/SKILL.md) 和 [`../../lark-doc/references/lark-doc-fetch.md`](../../lark-doc/references/lark-doc-fetch.md)
- 需要验证 Sheet 内容:[`../../lark-sheets/SKILL.md`](../../lark-sheets/SKILL.md)
- 需要 Drive 搜索:[`lark-drive-search.md`](lark-drive-search.md)
- 需要资源解析:[`lark-drive-inspect.md`](lark-drive-inspect.md)
## 适用范围
本 workflow 用于根据用户给出的主题、关键词或内容线索,在云空间 / 云盘 / Wiki / 电子表格等 Workspace 资源中查找相关资料,并在用户确认后统一移动到指定 Drive 文件夹或 Wiki 节点下。
适用触发语包括:
- "帮我找到和某主题相关的文档并放到这个文件夹"
- "把所有关于某项目的资料收集到知识库节点下"
- "找出包含某内容的资料,确认后移动到新建目录"
- "按这个关键词搜索我负责的资料,把相关资料归档"
默认搜索范围是当前用户 owner / 负责的 Workspace 资源,即 `owner_scope=mine`。只有用户明确要求“不限 owner”“包括共享给我的”“所有我能看到的文档”或“全量搜索”时才使用 `owner_scope=all_visible` 进入扩展召回模式。
不要求用户先限定文件夹或知识库范围。只有用户明确指定范围时,才使用 `--folder-tokens``--space-ids` 或其他显式限制。
## 非目标
默认不生成:
- 长篇研究报告
- 内容总结文档
- Sheet 清单或统计看板
- 自动权限治理报告
默认禁止执行:
- 未确认前创建文件夹或 Wiki 节点
- 未确认前移动资源
- 删除资源、重命名资源或修改公开权限
- 自动批量申请权限
- 把无权限或无法验证的资源加入移动计划
- 把移动权限未知或不具备移动资格的资源加入移动计划
如果用户明确要求把结果写入 Sheet / Doc切到对应专项能力本 workflow 的默认产物是移动后的资源归档结果。
## Agent 执行约束
触发本 workflow 后agent 必须:
1. 按“执行状态机”的顺序执行。
2. 维护“运行时状态”中的字段。
3. 执行某个状态前,先读取本文档 `## 渐进加载关系` 表格中该状态对应的文档。
4. 用户可见说明、字段说明和 UI 文案使用中文。
5. 状态名、字段名、枚举值、命令名保留英文稳定标识。
6.`CONFIRM_CONTEXT``CONFIRM_EXECUTION` 作为强用户确认门:前者确认主题、目标位置、身份、搜索范围、可选限制和目标解析结果后才能搜索;后者确认创建目标和移动资源后才能写入。
7. 进入 `EXECUTE` 前,不得创建目标文件夹 / 节点,也不得移动资源。
8. 必须展示每个相关性分组中的资源名称;低置信分组可以折叠,但必须可查看。
9. 默认只移动 `high` 相关资源;`medium` 资源必须由用户显式选择。
10. 即使用户可见列表分页展示,也必须维护完整内部状态。
11. `RESOURCE_RESOLVE``CONTENT_VERIFY` 是两个独立的强制阶段,不得合并;不得用搜索结果、标题或摘要直接替代 `CONTENT_VERIFY`,也不得从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`
12. 触发后锁定 `workflow_id=topic_move_collector`;执行期间不得自动切换到其他 workflow。
13. 如果认为需要切换 workflow必须停止并向用户说明原因等待用户确认。
14. `RESOURCE_RESOLVE` 是移动资格门禁;只有确认 `move_permission_state=movable``target_write_state=confirmed` 的资源才能进入默认移动链路。
## 用户展示 UI 规则
所有用户可见 UI 都必须包含:
1. 已经完成的关键结果。
2. 下一步会做什么,以及是否会产生写操作。
3. 如果 `wait_for_user=true`,明确告诉用户可以选择的动作。
4. 如果无需用户操作,明确说明将继续执行,避免用户误以为流程停住。
典型动作包括:确认继续、修改主题 / 目标 / 限制、展开更多结果、调整相关性分组、选择中相关资源、确认执行、取消执行。
## 职责边界
| 文件 | 负责 | 不负责 |
|------|------|--------------|
| `lark-drive-workflow-topic-move-collector.md` | 触发规则、全局约束、状态机、渐进加载关系、命令族白名单 | 具体阶段规则、UI 模板、执行细节 |
| `lark-drive-workflow-topic-move-collector-setup.md` | `PARSE_INPUT``RESOLVE_TARGET``CONFIRM_CONTEXT``TargetLocation` | 搜索执行、相关性分类、写操作 |
| `lark-drive-workflow-topic-move-collector-recall.md` | `SEARCH_RECALL``RECALL_ENHANCE`、搜索 query 策略、去重、`CandidateItem` | 资源 token 解析、内容验证、写操作 |
| `lark-drive-workflow-topic-move-collector-resolve-verify.md` | `RESOURCE_RESOLVE``CONTENT_VERIFY`、权限矩阵、`ResourceItem` | 相关性分类、移动计划、写操作 |
| `lark-drive-workflow-topic-move-collector-review-plan.md` | `RELEVANCE_CLASSIFY``PLAN_MOVE``MovePlanItem`、展示分组 | 资源解析、内容验证、写操作执行、恢复 |
| `lark-drive-workflow-topic-move-collector-execute.md` | `CONFIRM_EXECUTION``EXECUTE``VERIFY``RESTORE``RollbackSnapshotItem`、执行日志 | 搜索、分类和计划 schema |
## 运行时状态
本 workflow 扩展共享 Artifact Contract。agent 在一次 workflow 运行中必须维护以下专项内部字段:
| 字段 | 说明 |
|-------|------|
| `current_state` | 当前状态机节点。 |
| `topic` | 用户确认后的主题、关键词、同义词和排除词。 |
| `target_location` | 目标位置解析结果,见 setup 文件的 `TargetLocation`。 |
| `identity` | 执行身份;默认优先 `--as user`。 |
| `owner_scope` | 搜索 owner 范围;默认 `mine`,仅搜索当前用户 owner / 负责的资源;用户明确要求扩展时才为 `all_visible`。 |
| `constraints` | 用户显式确认的类型、时间、创建人、范围等限制。 |
| `allow_cross_container_move` | 是否允许跨 Drive / Wiki 容器移动;默认允许,但必须展示给用户确认。 |
| `recall_query_states` | 每个基础 / 增强 query 的分页状态、累计页数、`next_page_token``has_more`、完成或阻塞状态。 |
| `candidate_items` | 搜索召回结果,包含 query 证据和去重信息。 |
| `resource_items` | 解析后的标准资源列表。 |
| `content_verify_completed` | 内容验证阶段完成标记;`resource_items` 新建或变化时重置为 `false`,只有全部资源都有验证状态或跳过原因后才设为 `true`。 |
| `relevance_groups` | 高相关、中相关、低相关、无权限、无移动权限、移动权限未知、无法验证、不可移动分组。 |
| `move_plan_items` | 经用户选择后生成的完整移动计划,包含稳定资源关联、不可变命令参数、权限快照和恢复输入。 |
| `execution_journal` | 写操作日志,用于验证和恢复。 |
| `rollback_snapshot` | 写操作前位置快照,仅用于失败恢复或用户要求恢复。 |
| `display_page_state` | 用户可见列表的分页、筛选和展开状态。 |
## 执行状态机
| 状态 | Protocol Step | 进入条件 | agent 必须执行 | 用户可见输出 | `wait_for_user` | 下一状态 |
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_INPUT` | `route` / `scope` | workflow 被触发 | 加载 setup 文档;解析主题、目标、身份和限制 | 澄清问题或解析摘要 | 必填字段缺失时为 `true` | `RESOLVE_TARGET` |
| `RESOLVE_TARGET` | `scope` | 主题和目标已获得 | 解析已有目标,或解析待创建目标;按解析状态分流 | 目标解析结果或 blocker | 非 `resolved` 时为 `true` | `resolved` 时进入 `CONFIRM_CONTEXT`;否则保持本状态 |
| `CONFIRM_CONTEXT` | `scope` | `target_resolve_status=resolved` | 展示主题、目标、身份、限制和跨容器设置 | 搜索前确认 UI | `true` | `SEARCH_RECALL` |
| `SEARCH_RECALL` | `read` | 用户确认上下文 | 用原始关键词、默认 owner 范围和显式限制执行基础召回;按每批最多 5 页自动续批 | 搜索进度 / 基础统计 | 阻塞时为 `true` | 所有基础 query 完成后进入 `RECALL_ENHANCE` |
| `RECALL_ENHANCE` | `read` | 所有基础 query 已完成 | 执行覆盖增强 query按每批最多 5 页自动续批并合并结果 | 增强召回摘要 | 阻塞时为 `true` | 所有增强 query 完成后进入 `RESOURCE_RESOLVE` |
| `RESOURCE_RESOLVE` | `read` | 候选列表已准备 | 解析 token、类型、父级位置、owner 和移动资格 | 解析进度 / 阻塞摘要 | 阻塞时为 `true` | `CONTENT_VERIFY` |
| `CONTENT_VERIFY` | `read` | 资源列表已准备 | 对支持的资源做有界内容读取,并为其余资源写入跳过原因 | 验证进度 / 验证摘要 | 阻塞时为 `true` | `RELEVANCE_CLASSIFY` |
| `RELEVANCE_CLASSIFY` | `assess` | 证据已准备 | 按相关性和可执行性分组 | 分组结果列表 | `false` | `PLAN_MOVE` |
| `PLAN_MOVE` | `assess` / `plan` | 分组完成 | 基于默认规则和用户可选项生成移动计划 | 草案计划和选择项 | `true` | `CONFIRM_EXECUTION` |
| `CONFIRM_EXECUTION` | `confirm` | 用户要求执行 | 展示创建、移动、跳过项和风险 | 写操作确认 UI | `true` | `EXECUTE``PLAN_MOVE``DONE` |
| `EXECUTE` | `execute` | 用户明确确认写操作 | 需要时先创建目标,再移动确认资源 | 执行进度 | 阻塞时为 `true` | `VERIFY``RESTORE` |
| `VERIFY` | `verify` | 执行完成 | 验证目标位置下的移动结果 | 验证结果 | 提供恢复选项时为 `true` | `DONE``RESTORE` |
| `RESTORE` | `recovery confirm` / `recovery execute` | 用户要求恢复 | 仅基于快照和日志恢复 | 恢复确认 / 结果 | 写操作前为 `true` | `VERIFY``DONE` |
| `DONE` | `done` | 无后续操作 | 停止 | 最终回复 | `false` | 结束 |
### 状态跳转硬约束
1. `RESOLVE_TARGET` 只有在 `target_resolve_status=resolved` 时才能进入 `CONFIRM_CONTEXT``ambiguous``unsupported``permission_denied` 必须保持在 `RESOLVE_TARGET` 并等待用户选择、更换目标或结束。
2. `SEARCH_RECALL` 只有在全部基础 query 的 `has_more=false` 时才能进入 `RECALL_ENHANCE`;单批达到 5 页但仍有更多结果时必须自动续批,不得提前跳转。
3. `RECALL_ENHANCE` 只有在全部增强 query 的 `has_more=false` 时才能进入 `RESOURCE_RESOLVE`;不得直接进入 `RELEVANCE_CLASSIFY``PLAN_MOVE`
4. `RESOURCE_RESOLVE` 必须为每个 `CandidateItem` 生成对应的 `ResourceItem`,或生成明确的解析失败 / 权限受限状态。
5. `RESOURCE_RESOLVE` 必须为每个 `ResourceItem` 写入 `move_permission_state``move_permission_basis`;完成后将 `content_verify_completed=false`,下一状态只能是 `CONTENT_VERIFY`
6. 禁止从 `RESOURCE_RESOLVE` 直接进入 `RELEVANCE_CLASSIFY`。即使没有任何资源可以读取正文,也必须进入 `CONTENT_VERIFY`,为每项写入验证状态或跳过原因并输出验证摘要。
7. `CONTENT_VERIFY` 必须为每个 `ResourceItem` 写入内容证据、搜索证据复用说明,或不可验证原因;移动权限未知或无移动权限的资源可以只写入跳过验证原因。
8. 只有当 `resource_items` 已准备、每项都有验证状态或跳过原因,且 `content_verify_completed=true` 时,才能进入 `RELEVANCE_CLASSIFY`
9. 用户调整相关性分组后,必须回到 `RELEVANCE_CLASSIFY` 输出调整后的分组结果,再进入 `PLAN_MOVE` 重新生成计划。
### Workflow 切换门禁
只有以下情况允许考虑切换 workflow
1. 用户明确说不再做主题资料收集,改为整理整个目录结构或生成盘点方案。
2. 当前 workflow 明确无法覆盖用户的新目标。
3. 用户要求的是目录结构治理,而不是查找主题相关资料并移动。
即使满足以上条件,也不得自动切换;必须先向用户说明原因并等待确认。
## 渐进加载关系
| 状态 | 必读文档 |
|-------|---------------|
| `PARSE_INPUT` / `RESOLVE_TARGET` / `CONFIRM_CONTEXT` | [`lark-drive-workflow-topic-move-collector-setup.md`](lark-drive-workflow-topic-move-collector-setup.md) |
| `SEARCH_RECALL` / `RECALL_ENHANCE` | [`lark-drive-workflow-topic-move-collector-recall.md`](lark-drive-workflow-topic-move-collector-recall.md) |
| `RESOURCE_RESOLVE` / `CONTENT_VERIFY` | [`lark-drive-workflow-topic-move-collector-resolve-verify.md`](lark-drive-workflow-topic-move-collector-resolve-verify.md) |
| `RELEVANCE_CLASSIFY` / `PLAN_MOVE` | [`lark-drive-workflow-topic-move-collector-review-plan.md`](lark-drive-workflow-topic-move-collector-review-plan.md) |
| `CONFIRM_EXECUTION` / `EXECUTE` / `VERIFY` / `RESTORE` | [`lark-drive-workflow-topic-move-collector-execute.md`](lark-drive-workflow-topic-move-collector-execute.md) |
## 命令映射
| 状态 | 允许的命令族 | 用途 |
|-------|--------------------------|---------|
| `RESOLVE_TARGET` | `drive +inspect``wiki +node-get``wiki +space-list`、仅用于查找文件夹候选的 `drive +search` | 解析目标位置 |
| `SEARCH_RECALL` / `RECALL_ENHANCE` | `drive +search` | 搜索召回和覆盖增强 |
| `RESOURCE_RESOLVE` | `drive +inspect``wiki +node-get``drive metas batch_query`、必要时 `drive permission.members auth` | 解析标准 token、owner、权限信号和移动资格 |
| `CONTENT_VERIFY` | `docs +fetch``sheets +read``sheets +find`、必要时 `drive +preview` | 验证内容证据 |
| `EXECUTE` | `drive +create-folder``wiki +node-create``drive +move``wiki +move``wiki +move-to-drive``drive +task_result` | 执行已确认写操作 |
| `VERIFY` | `drive files list``wiki +node-list``wiki +node-get``drive +inspect``drive +task_result` | 验证执行结果 |
| `RESTORE` | `drive +move``wiki +move``drive +delete``wiki +node-delete``drive +task_result` | 恢复已确认资源并清理本次新建目标 |
## 引用文档
- [输入与目标确认](lark-drive-workflow-topic-move-collector-setup.md)
- [召回](lark-drive-workflow-topic-move-collector-recall.md)
- [资源解析与内容验证](lark-drive-workflow-topic-move-collector-resolve-verify.md)
- [审核与计划](lark-drive-workflow-topic-move-collector-review-plan.md)
- [执行](lark-drive-workflow-topic-move-collector-execute.md)
- [lark-drive-search](lark-drive-search.md)
- [lark-drive-inspect](lark-drive-inspect.md)
- [lark-drive-move](lark-drive-move.md)
- [lark-drive-create-folder](lark-drive-create-folder.md)
- [lark-drive-delete](lark-drive-delete.md)
- [lark-wiki-move](../../lark-wiki/references/lark-wiki-move.md)
- [lark-wiki-move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md)
- [lark-wiki-node-create](../../lark-wiki/references/lark-wiki-node-create.md)
- [lark-wiki-node-delete](../../lark-wiki/references/lark-wiki-node-delete.md)

View File

@@ -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`
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``topic_move_collector``R2-R3/S3`
## 加载与拆分边界
@@ -108,10 +108,11 @@ Structure Level
## Workflow Registry
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| 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) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
| `topic_move_collector` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-topic-move-collector.md`](lark-drive-workflow-topic-move-collector.md) | 按主题、关键词或内容线索跨容器搜索资料,验证相关性和移动资格,并在用户确认后归档到 Drive 文件夹或 Wiki 节点 |
## Workflow Loading

View File

@@ -106,7 +106,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
| [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) |
| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket |
| [`+chat-members-add`](references/lark-im-chat-members-add.md) | Add users and/or bots to a group chat; user/bot; batches --users (open_id) and --bots (app_id) into up to 2 API calls under best-effort semantics; returns a merged succeeded/invalid/not_existed/pending_approval ledger |
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) |
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
@@ -144,7 +143,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
### chat.members
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`. Prefer `+chat-members-add` over calling this directly — it batches user/bot invites and merges the result into one ledger.
- `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`.
- `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request.
### chat.user_setting

View File

@@ -1,71 +0,0 @@
# im +chat-members-add
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
Add users and/or bots to a group chat in one command. `--users` (open_id) and `--bots` (app_id) map to two independent underlying calls — `chat.members.create` only accepts one `member_id_type` per request, so a single call cannot mix user and bot IDs. This shortcut issues both calls as needed and merges the results into one ledger.
This skill maps to the shortcut: `lark-cli im +chat-members-add` (internally calls `POST /open-apis/im/v1/chats/{chat_id}/members` up to twice, with `succeed_type=1`/best-effort).
## Commands
```bash
# Add users only
lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a,ou_b
# Add bots only
lark-cli im +chat-members-add --chat-id oc_xxx --bots cli_x
# Add both (two API calls internally, merged into one result)
lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a,ou_b --bots cli_x
# JSON output / preview the request
lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a --format json
lark-cli im +chat-members-add --chat-id oc_xxx --users ou_a --bots cli_x --dry-run
```
## Parameters
| Parameter | Required | Limits | Description |
|------|------|------|------|
| `--chat-id <id>` | Yes | `oc_xxx` | Target chat |
| `--users <ids>` | No* | `ou_xxx` (comma-separated or repeated), max 50 | User open_ids to invite |
| `--bots <ids>` | No* | `cli_xxx` (comma-separated or repeated), max 5 | Bot app_ids to invite |
| `--format json` | No | - | Output as JSON |
| `--dry-run` | No | - | Preview the request(s) without executing them |
\* At least one of `--users`/`--bots` is required.
> Supports both `--as user` (default) and `--as bot`. The caller must be in the target chat; for bot calls, invited users must be within the app's availability; for internal chats the operator must belong to the same tenant.
## Output Fields
| Field | Description |
|------|------|
| `chat_id` | The target chat ID |
| `succeeded_id_list` | IDs (users + bots merged) that were actually added to the chat |
| `invalid_id_list` | IDs that are resigned / invisible / from a disabled app |
| `not_existed_id_list` | IDs that don't exist |
| `pending_approval_id_list` | IDs submitted but awaiting owner/admin approval — **not yet actually in the chat** |
| `call_errors` | Whole-call failures (not per-ID) — each entry has `member_type`, the `id_list` that call carried, and `error` |
| `success_count` / `failure_count` / `total` | `total` = requested ID count (post-validation, per-flag deduped); `failure_count` sums the three failure buckets plus every `call_errors[].id_list`; `success_count + failure_count == total` always holds |
## Partial failure: valid IDs still get added
The shortcut always uses best-effort semantics (`succeed_type=1`): a single resigned/nonexistent/unapprovable ID does not block the rest of the batch. When `failure_count > 0`, the command exits non-zero (`ok: false` in the JSON envelope) even though some IDs did succeed — always check `success_count`/`failure_count`, not just the exit code, to know exactly what happened.
`pending_approval_id_list` is a distinct case: those members were **not** added — they're waiting on an owner/admin decision. Don't treat that bucket as "succeeded".
## Auth-failure short-circuit (both calls, both auth errors)
This only applies when **both** `--users` and `--bots` were supplied (so both calls are attempted) **and both** calls fail with an auth/permission-classified error (e.g. missing scope, unauthenticated). In that specific case the command returns that error directly as a normal error envelope — it does **not** build the usual ledger (`chat_id`/`succeeded_id_list`/.../`success_count`), and does not go through the partial-failure path above. If only one of `--users`/`--bots` is given, or only one of the two calls fails this way, the normal ledger output still applies.
## Common Errors and Troubleshooting
| Symptom | Root Cause | Solution |
|---------|---------|---------|
| `invalid --chat-id ...: must be an open_chat_id starting with oc_` | `--chat-id` missing or malformed | Provide the `oc_xxx` chat ID |
| `at least one of --users or --bots is required` | Both flags omitted | Provide at least one |
| `invalid --users value ...: must start with "ou_"` | Wrong ID type in `--users` | Use `open_id` (`ou_xxx`), not `union_id`/`user_id`/`app_id` |
| `invalid --bots value ...: must start with "cli_"` | Wrong ID type in `--bots` | Use the app's `app_id` (`cli_xxx`) |
| `--users exceeds the maximum of 50` / `--bots exceeds the maximum of 5` | Batch too large | Split into multiple calls |
| Permission denied | Missing `im:chat.members:write_only`, or caller not in the chat / not owner-admin when restricted | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:write_only"`; confirm the caller is in the chat |

View File

@@ -14,30 +14,85 @@ metadata:
**身份**OKR 操作默认使用 `--as user`(查看当前用户/上下级的 OKR 时)。也支持 `--as bot` 查看他人 OKR需相应权限
## 快速决策
| 用户需求 | 操作路径 | 参考文档 |
|----------------|----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 查看自己/他人的 OKR | 获取用户 ID -> `+cycle-list` -> `+cycle-detail` -> 按需查指标/进展记录 | [`cycle-list`](references/lark-okr-cycle-list.md), [`cycle-detail`](references/lark-okr-cycle-detail.md), [`indicators`](references/lark-okr-indicators.md), [`progress-list`](references/lark-okr-progress-list.md) |
| 为自己写一组 OKR | 优先用 `+batch-create` 创建 Objective/KR 骨架 | [`batch-create`](references/lark-okr-batch-create.md), [`contentblock`](references/lark-okr-contentblock.md) |
| 只新增一条 O 或单条 KR | 用 `+create` | [`create`](references/lark-okr-create.md) |
| 编辑内容/备注/截止时间 | 用 `+patch` | [`patch`](references/lark-okr-patch.md) |
| 修改 OKR 分数 | 只有用户明确说“分数”“评分”“打分”“score”时才用 `+patch --score`;分数不是进度/完成度 | [`patch`](references/lark-okr-patch.md) |
| 调整顺序或权重 | 用 `+reorder` / `+weight` | [`reorder`](references/lark-okr-reorder.md), [`weight`](references/lark-okr-weight.md) |
| 更新数字进度/完成度 | 百分比或不带单位数字用 `+indicator-update`;需要改单位/目标值时查指标后用 `indicators patch` | [`indicator-update`](references/lark-okr-indicator-update.md), [`indicators`](references/lark-okr-indicators.md) |
| 写文字进展 | 用 `+progress-create`;如果文本和数字都有,百分比或默认单位可使用 `--progress-percent` 统一改,非百分比单位更新量化指标 | [`progress-create`](references/lark-okr-progress-create.md), [`progress-list`](references/lark-okr-progress-list.md), [`progress-update`](references/lark-okr-progress-update.md) |
| 对齐目标 | 直接按对齐关系工作流处理 | [`alignments`](references/lark-okr-alignments.md) |
分类只在用户明确要求分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时处理:用 `lark-cli okr categories list --params '{"owner_type":"user","page_size":100}' --as user` 查可用分类,选择语义合适且 `enabled=true` 的分类 ID分类可后续调整不必停下等待用户确认。
获取当前用户用 `contact +get-user`;按姓名/邮箱查他人用 `contact +search-user`,拿到 `open_id` 后再查 OKR。
```bash
lark-cli contact +search-user --query "张三" --has-chatted --as user
```
最常用 OKR 命令示例:
```bash
# 查用户周期,再用周期 ID 查详情
lark-cli okr +cycle-list --user-id "ou_xxx" --as user
lark-cli okr +cycle-detail --cycle-id 7000000000000000001 --as user
# 批量创建 Objective/KR
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input '[{"text":"提升产品用户体验","notes":"关注核心流程和用户反馈","krs":[{"text":"核心流程满意度达到 4.8 分"}]}]' \
--as user
# 更新数字进度/完成度
lark-cli okr +indicator-update \
--level key-result \
--id 7000000000000000003 \
--value 75 \
--as user
```
分数和进度不要混用:用户说“进度”“完成度”“当前做到 75%”时,通常是在改量化指标或写进展记录,不是在改 `score`。只有明确要求修改 OKR 分数/评分/打分时,才使用 [`+patch --score`](references/lark-okr-patch.md)`score` 取值是 0-1最多一位小数。
进度判断规则:用户说“进度”“完成度”时,先判断是否是量化数字。数字进度通常对应量化指标;不可量化文本对应进展记录。需要修改指标单位时看 [`lark-okr-indicators.md`](references/lark-okr-indicators.md)
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值(简单场景推荐)。更复杂的指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KRcontent、notes、score、deadline |
| Shortcut | 说明 |
|----------------------------------------------------------------|-----------------------------------------------------------------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 分页获取特定用户的 OKR 周期列表,可以`--time-range` 对当前页后置筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+create`](references/lark-okr-create.md) | 创建单个 Objective可带备注或向已有 Objective 新增 KR |
| [`+progress-list`](references/lark-okr-progress-list.md) | 分页获取目标或关键结果的进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective(可带备注)和 KR |
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的当前进度指标。更复杂的量化指标操作见 [量化指标管理](references/lark-okr-indicators.md) |
| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KRcontent、notes、score、deadline |
### 创建场景选择
- **单条创建优先用 [`+create`](references/lark-okr-create.md)**:适合创建一个 Objective或给已有 Objective 增加一个 KR。
- **批量创建用 [`+batch-create`](references/lark-okr-batch-create.md)**:适合一次创建多个 Objective并可同时附带多个 KR。
- 如果你只需要修改已有 Objective / KR 的内容、备注、分数或截止时间,使用 [`+patch`](references/lark-okr-patch.md)。
## 格式说明
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明以及简化的半纯文本SemiPlainContent格式的进一步说明。
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note
字段使用的富文本格式说明以及简化的半纯文本SemiPlainContent格式的进一步说明。
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
## API Resources
@@ -56,18 +111,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`
### cycles
- `list` — 批量获取用户周期
- `objectives_position` — 更新用户周期下全部目标的位置
- 请求中必须携带对应周期下全部目标的 ID否则会参数校验失败。以传入的目标ID顺序重新排列目标。
- `objectives_weight` — 更新用户周期下全部目标的权重
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。例如周期下有 2 个目标时:
- 正确指令示例如下:
``` bash
lark-cli okr cycles objectives_weight --params '{"cycle_id": "7000000000000000001"}' --data '{"objective_weights": [{"objective_id": "7000000000000000002", "weight": 0.7}, {"objective_id": "7000000000000000003", "weight": 0.3}]}' --as user
```
### cycle.objectives
- `create` — 创建目标
- `list` — 批量获取用户周期下的目标
### indicators
@@ -110,7 +156,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`
### objective.key_results
- `create` — 创建关键结果
- `list` — 批量获取目标下的关键结果
## 不在本 skill 范围

View File

@@ -10,23 +10,7 @@
# 批量创建 2 个 Objective各带 2 个 KR。
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input '[
{
"text": "提升产品用户体验",
"mention": ["ou_xxxxxxxx"],
"krs": [
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
{"text": "用户满意度达到 4.8 分"}
]
},
{
"text": "拓展新市场份额",
"krs": [
{"text": "新增 10 个城市覆盖"},
{"text": "市场份额提升至 25%"}
]
}
]' \
--input '[{"text":"提升产品用户体验","mention":["ou_xxxxxxxx"],"notes":"重点关注核心路径体验","krs":[{"text":"页面加载速度提升 50%","mention":["ou_yyyyyyyy"]},{"text":"用户满意度达到 4.8 分"}]},{"text":"拓展新市场份额","krs":[{"text":"新增 10 个城市覆盖"},{"text":"市场份额提升至 25%"}]}]' \
--as user
# 从文件读取输入
@@ -44,17 +28,22 @@ lark-cli okr +batch-create \
```
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
- Objective 的 notes / notes_mention 是可选参数用于创建目标备注KR 不支持备注。
- Objective 的 category_id 是可选参数;也可以通过 `--category-id` 给所有未显式设置分类的 Objective 指定默认分类。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|-----------|------------------------------------------------------------|
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `-` 从 stdin 读取。 |
| `--category-id` | 否 | — | 默认 Objective 分类 ID。仅用于 input 中未设置 `category_id` 的 Objective。通常不需要传入见下方“分类提示”。 |
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
> **分类提示**:当用户明确要求设置 Objective 分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时,可以配置 category-id 字段进行创建。先运行 `lark-cli okr categories list --as user` 查看可用分类,然后选择一个语义合适且 `enabled=true` 的分类 ID 作为 `category-id`。分类创建后可以再调整;不必因为分类选择停下等待用户确认。
## 输入格式
```json
@@ -62,6 +51,9 @@ lark-cli okr +batch-create \
{
"text": "Objective 内容",
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
"notes": "Objective 备注",
"notes_mention": ["ou_xxxxxxxx"],
"category_id": "7249339036661170180",
"krs": [
{
"text": "KR 内容",
@@ -72,6 +64,15 @@ lark-cli okr +batch-create \
]
```
字段说明:
- `text`Objective 或 KR 内容,必填。
- `mention`:追加到内容后的用户 mention可选。
- `notes`Objective 备注文本,可选,仅 Objective 支持。
- `notes_mention`:追加到 Objective 备注后的用户 mention可选仅在 `notes` 存在时有意义。
- `category_id`Objective 分类 ID可选会覆盖命令级 `--category-id`
- `krs`:当前 Objective 下要创建的 KR 列表,可选。
## 工作流程
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID

View File

@@ -0,0 +1,173 @@
# okr +create
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建单个 OKR 目标Objective或关键结果Key Result。这是单条写入场景的首选 shortcut如果需要一次创建多个 Objective 及其 KR可使用 [`+batch-create`](lark-okr-batch-create.md)。
## 推荐命令
```bash
# 在指定周期下创建一个 Objective默认 simple 风格)
lark-cli okr +create \
--level objective \
--cycle-id 7000000000000000001 \
--content '{"text":"提升北极星指标","mention":["ou_xxxxxxxx"]}' \
--notes '{"text":"重点关注活跃用户和转化漏斗"}' \
--as user
# 在已有 Objective 下创建一个 KR
lark-cli okr +create \
--level key-result \
--objective-id 7000000000000000002 \
--content '{"text":"季度留存率提升到 45%"}' \
--as user
# 使用 richtext 风格创建 Objective完整 ContentBlock JSON
lark-cli okr +create \
--level objective \
--cycle-id 7000000000000000001 \
--style richtext \
--content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"建立跨部门协作机制"}}]}}]}' \
--as user
# 预览 API 调用而不实际执行
lark-cli okr +create \
--level key-result \
--objective-id 7000000000000000002 \
--content '{"text":"完成 3 次核心流程优化"}' \
--dry-run \
--as user
```
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|-----------|--------------------------------------------------------------------------------------------------------------------|
| `--level` | 是 | — | 创建层级:`objective`(创建目标)\| `key-result`(在已有目标下创建 KR |
| `--cycle-id` | 条件 | — | OKR 周期 IDint64 类型)。当 `--level=objective` 时**必填**。 |
| `--objective-id` | 条件 | — | Objective IDint64 类型)。当 `--level=key-result` 时**必填**。 |
| `--style` | 否 | `simple` | 内容输入风格:`simple`(半纯文本 JSON推荐 \| `richtext`(完整 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
| `--content` | 是 | — | 内容。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取或 `-` 从 stdin 读取。 |
| `--notes` | 否 | — | Objective 备注,仅 `--level=objective` 支持。根据 `--style` 指定格式,支持 `@文件路径``-` 从 stdin 读取。 |
| `--category-id` | 否 | — | Objective 分类 ID`--level=objective` 支持。通常不需要传入,见下方“分类提示”。 |
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id`。影响 mention 中用户 ID 的解释方式。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
| `--format` | 否 | `json` | 输出格式。 |
> **分类提示**:当用户明确要求设置 Objective 分类,或创建 Objective 返回 `invalid parameters` 且怀疑租户强制开启分类时,可以配置 --category-id 参数进行创建。先运行 `lark-cli okr categories list --as user` 查看可用分类,然后选择一个语义合适且 `enabled=true` 的分类 ID 作为 `--category-id`。分类创建后可以再调整;不必因为分类选择停下等待用户确认。
## 输入格式
### `--style simple`(默认)
推荐大多数创建场景使用 `simple` 风格。`--content``--notes` 都使用 `SemiPlainContent` JSON
```json
{
"text": "提升北极星指标",
"mention": ["ou_xxxxxxxx"]
}
```
规则:
- `text` 必填,且不能为空白字符串
- `mention` 可选;如果传入,数组中的每个用户 ID 都不能为空字符串
- `--notes` 仅适用于 Objective创建 KR 时传 `--notes` 会报错
- 同一条命令只有一个 flag 可以使用 `-` 读取 stdin如果 `--content -``--notes` 请使用内联 JSON 或 `@文件路径`
### `--style richtext`
当你需要精确控制段落结构、插入文档链接,或使用完整富文本块结构时,使用 `richtext` 风格:
```json
{
"blocks": [
{
"block_element_type": "paragraph",
"paragraph": {
"elements": [
{
"paragraph_element_type": "textRun",
"text_run": {
"text": "建立跨部门协作机制"
}
}
]
}
}
]
}
```
规则:
- `blocks` 至少需要有一个非空段落或图片块
- 不能传空 `blocks`,也不能传只有空段落元素的内容
- 更多结构说明见 [ContentBlock 富文本格式](lark-okr-contentblock.md)
## 工作流程
1. 如果要创建 Objective先使用 `+cycle-list` 获取目标周期的 `cycle_id`
2. 如果要给已有 Objective 新增 KR先通过 `+cycle-detail` 或其他 OKR 查询命令拿到 `objective_id`
3. 选择输入风格:
- **推荐**`simple`,适合普通文本和 mention。
- 需要复杂富文本时:`richtext`
4. 执行 `lark-cli okr +create ...`
5. 报告结果:
- 创建 Objective 时返回新的 `objective_id`
- 创建 KR 时返回新的 `key_result_id`,并附带父 `objective_id`
## Dry-run 对应接口
- `--level=objective`
- `POST /open-apis/okr/v2/cycles/:cycle_id/objectives`
- `--level=key-result`
- `POST /open-apis/okr/v2/objectives/:objective_id/key_results`
## 输出
### 创建 Objective 成功
```json
{
"level": "objective",
"objective_id": "7000000000000000002"
}
```
### 创建 KR 成功
```json
{
"level": "key-result",
"objective_id": "7000000000000000002",
"key_result_id": "7000000000000000003"
}
```
## 常见错误与处理
- `--level=objective` 但未传 `--cycle-id`
- 补充有效的周期 ID
- `--level=key-result` 但未传 `--objective-id`
- 补充已有 Objective 的 ID
- `--content` 为空、不是合法 JSON或内容结构为空
-`--style` 对应格式修正输入
-`simple` 风格中传了 `docs``images`
- 改用 `--style richtext`,或移除这些字段
## 何时用 +create何时用 +batch-create
| 命令 | 适用场景 |
|------|----------|
| `+create` | 创建单个 Objective或向已有 Objective 新增单个 KR |
| `+batch-create` | 一次创建多个 Objective并可同时为每个 Objective 创建多个 KR |
## 参考
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
- [OKR 业务实体](lark-okr-entities.md) -- Objective、KR、周期等基础概念
- [ContentBlock 格式](lark-okr-contentblock.md) -- content/notes 字段的另一种输入风格,支持完整富文本格式
- [okr +batch-create](lark-okr-batch-create.md) -- 批量创建多个 Objective / KR
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -2,18 +2,21 @@
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
列出指定用户的 OKR 周期,支持可选的时间范围过滤。
列出指定用户的一页 OKR 周期,支持外部控制翻页和可选的时间范围后置过滤。
## 推荐命令
```bash
# 列出用户的所有周期
# 获取用户周期第一页 (默认页大小为 100 按时间倒序排列,一般不用翻页)
lark-cli okr +cycle-list --user-id "ou_xxx"
# 获取下一页
lark-cli okr +cycle-list --user-id "ou_xxx" --page-size 100 --page-token "7000000000000000002"
# 使用特定的用户 ID 类型列出周期
lark-cli okr +cycle-list --user-id "xxx" --user-id-type user_id
# 列出时间范围的周期(例如 2025-01 到 2025-06
# 列出当前返回页中与时间范围重叠的周期(例如 2025-01 到 2025-06
lark-cli okr +cycle-list --user-id "ou_xxx" --time-range "2025-01--2025-06"
# 预览 API 调用而不实际执行
@@ -26,7 +29,9 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|------------------|----|-----------|------------------------------------------------------------------|
| `--user-id` | 是 | — | OKR 所有者的用户 ID |
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--time-range` | 否 | — | 时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
| `--time-range` | 否 | — | 后置筛选条件:先按 `--page-size`/`--page-token` 请求一页,再在本地保留与该时间范围重叠的周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。 |
| `--page-size` | 否 | `100` | 每页数量,范围 `1-100`。 |
| `--page-token` | 否 | `""` | 上一次响应中的 `page_token`,留空表示第一页。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
| `--format` | 否 | `json` | 输出格式。 |
@@ -34,8 +39,11 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
1. 获取目标用户的 `open_id`(或其他 ID 类型)。如果用户说"我的 OKR 周期",先通过 `lark-cli contact +get-user` 获取当前用户的
ID。
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx"`,可选择使用 `--time-range`
3. 报告结果:找到的周期数量、每个周期的 ID、开始/结束时间和状态
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx" --page-size 100`,可选择使用 `--time-range`
3. 如果响应中 `has_more=true`,继续用返回的 `page_token` 调用下一页
4. 报告结果:每个周期的 ID、开始/结束时间和状态。
`--time-range` 是后置筛选条件,不会改变服务端分页窗口。也就是说,命令会先获取指定页,再过滤该页中的周期;如果需要完整时间范围结果,需要按 `has_more`/`page_token` 逐页拉取并合并。
## 输出
@@ -51,7 +59,8 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
"cycle_status": "normal"
}
],
"total": 1,
"has_more": true,
"page_token": "7000000000000000002",
"current_active_cycles": [
{
"id": "1234567890123456789",
@@ -66,6 +75,7 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
在这个周期信息中,这些字段值得关注:
- `id` 是这个周期的 ID你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情
- `has_more``page_token` 用于外部控制翻页;`has_more=true` 时,用 `--page-token` 原样传入本次返回的 `page_token` 获取下一页。
- `start_time` `end_time` 是周期的起止时间总是从某个月1日开始直到此月或之后某月的最后一日结束。
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 "2025-01-01开始2025-06-30结束" 的周期被称作 "2025 年 1-6 月" 周期,而
"2025-01-01开始2025-01-31结束" 的周期被称作 "2025 年 1 月"周期。

View File

@@ -50,6 +50,7 @@ Category (分类): Objective 的分组标签
- **当前周期**: 指周期的 start_time/end_time
指周期的 start_time / end_time 所在的时间段与当前时间重叠的周期(即: start_time <= 当前时间 且 end_time >= 当前时间)。 注意:时间重叠是判断当前周期的首要且必须的硬性条件,绝对不能仅仅根据 cycle_status == 1 去判断。 如果有多个符合时间重叠标准的周期,再在这些包含当前时间的周期中过滤,保留周期状态为 default (0) 或 normal (1) 的周期。如果仍然有多个,则选择其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。
- 如果用户没有提及,那么当前周期一般不考虑年度周期(起止时间从 01-01 至 12-31 的周期)
- **所有者**: 绝大多数所有者都是用户少部分租户启用了“团队OKR”功能所有者可能是部门。用户身份下只能编辑所有者为当前用户的
OKR。

View File

@@ -40,7 +40,9 @@ lark-cli okr +indicator-update \
1. 使用 `+cycle-list``+cycle-detail` 获取目标 ID 或 KR ID。
2. 如需查看当前指标值,使用 `objective.indicators list``key_result.indicators list` 查询。
3. 执行 `+indicator-update` 指定层级、ID 和新值
若当前量化指标没有 start_value/current_value/target_value/unit 这些字段,代表当前量化指标为未设置的默认初始进度
3. 执行 `+indicator-update` 指定层级、ID 和新值。
使用 +indicator-update 为默认初始进度设置当前值会将该量化指标配置为默认的百分比模式。若用户不希望将指标设置为百分比,请使用原生 API 详细设置,参考 [lark-okr-indicators.md](lark-okr-indicators.md)
4. 命令自动查询指标 ID 并更新当前值。
## 输出

View File

@@ -40,11 +40,11 @@ lark-cli okr objective.indicators list --objective-id "<目标ID>" [flags]
```bash
# 获取目标的量化指标
lark-cli okr objective.indicators list \
--objective-id 7652569715131075772
--objective-id 7000000000000000001
# 指定用户 ID 类型
lark-cli okr objective.indicators list \
--objective-id 7652569715131075772 \
--objective-id 7000000000000000001 \
--user-id-type "user_id"
```
@@ -60,6 +60,63 @@ lark-cli okr objective.indicators list \
返回 `indicator` 字段,包含该目标的量化指标详情。
示例返回值:
有进度时:
```json
{
"ok": true,
"identity": "user",
"data": {
"indicator": {
"create_time": "1782835200000", // 创建时间
"current_value": 60, // 当前值
"current_value_calculate_type": 0, // 当前值计算方式 0(手动更新)|2(按KR计算)|3(按拆解计算)。 仅当此处为 0 时,允许使用 patch API 更新当前值
"entity_id": "7000000000000000001",// 指标挂载的 Objective/KR id
"entity_type": 2, // 指标挂载在 Objective还是KR 上 2(Objective)|3(KR)
"id": "7000000000000000002", // 指标本身的 ID
"indicator_status": 0, // 指标状态 -1(未定义)|0(正常)|1(有风险)|2(延期)
"owner": { // 指标归属的用户
"owner_type": "user",
"user_id": "ou_xxx"
},
"start_value": 0, // 起始值, 默认0
"status_calculate_type": 0, // 状态计算方式
"target_value": 100, // 目标值, 默认 100
"unit": { // 指标单位,默认是公共的百分比
"unit_type": 0, // 单位类型 0(公共)|1(自定义)
"unit_value": "PERCENT" // 单位名
},
"update_time": "1782835200000" // 更新时间
}
}
}
```
默认初始进度:
```json
{
"ok": true,
"identity": "user",
"data": {
"indicator": {
"create_time": "1782835200000",
"entity_id": "7000000000000000001",
"entity_type": 2,
"id": "7000000000000000002",
"indicator_status": -1,
"owner": {
"owner_type": "user",
"user_id": "ou_xxx"
},
"status_calculate_type": 0,
"update_time": "1782835200000"
}
}
}
```
默认初始进度不携带 start_value/current_value/target_value/unit 等信息,若直接设置当前值,则使用百分比作为默认单位。
由于默认单位为百分比,当一定要计算数值时,可以视作 0%,但是向用户汇报默认初始进度时,应当明确对应的 O/KR 未设置进度这一点,以和真正的 0% 区别开。
---
## 二、查询关键结果的量化指标
@@ -187,12 +244,7 @@ lark-cli okr indicators patch \
```bash
lark-cli okr indicators patch \
--indicator-id "ind-123" \
--data '{
"current_value": 65.0,
"current_value_calculate_type": 0,
"indicator_status": 1,
"status_calculate_type": 0
}'
--data '{"current_value":65.0,"current_value_calculate_type":0,"indicator_status":1,"status_calculate_type":0}'
```
4. **验证更新结果**
@@ -210,10 +262,7 @@ lark-cli okr key_result.indicators list --key-result-id 7652569715131075780
# 2. 更新目标值和单位
lark-cli okr indicators patch \
--indicator-id 7652569715131075781 \
--data '{
"target_value": 500,
"unit": {"unit_type": 0, "unit_value": "YUAN"}
}'
--data '{"target_value":500,"unit":{"unit_type":0,"unit_value":"YUAN"}}'
```
## 参考

View File

@@ -2,17 +2,24 @@
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
获取目标Objective或关键结果Key Result所有进展记录列表。
获取目标Objective或关键结果Key Result一页进展记录列表,支持外部控制翻页
## 推荐命令
```bash
# 获取目标的所有进展记录
# 获取目标进展记录第一页 (默认页大小为 100一般不用翻页)
lark-cli okr +progress-list \
--target-id 1234567890123456789 \
--target-type objective
# 获取关键结果的所有进展记录
# 获取下一页进展记录
lark-cli okr +progress-list \
--target-id 1234567890123456789 \
--target-type objective \
--page-size 100 \
--page-token "7000000000000000002"
# 获取关键结果进展记录第一页
lark-cli okr +progress-list \
--target-id 9876543210987654321 \
--target-type key_result
@@ -26,14 +33,17 @@ lark-cli okr +progress-list \
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`department_id` \| `open_department_id` |
| `--page-size` | 否 | `100` | 每页数量,范围 `1-100`。 |
| `--page-token` | 否 | `""` | 上一次响应中的 `page_token`,留空表示第一页。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
| `--format` | 否 | `json` | 输出格式。 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取目标或关键结果的 ID。
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective`
3. 获取该目标或关键结果下的所有进展记录列表
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective --page-size 100`
3. 如果响应中 `has_more=true`,继续用返回的 `page_token` 调用下一页
4. 获取该目标或关键结果下的进展记录列表。
## 输出
@@ -41,7 +51,7 @@ lark-cli okr +progress-list \
```json
{
"progress": [
"progress_list": [
{
"progress_id": "1234567890123456789",
"modify_time": "2025-01-15 10:30:00",
@@ -52,13 +62,15 @@ lark-cli okr +progress-list \
}
}
],
"total": 1
"has_more": true,
"page_token": "7000000000000000002"
}
```
其中:
- `progress` — 进展记录数组
- `progress_list` — 进展记录数组
- `has_more``page_token` 用于外部控制翻页;`has_more=true` 时,用 `--page-token` 原样传入本次返回的 `page_token` 获取下一页。
- `content` 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
@@ -66,7 +78,7 @@ lark-cli okr +progress-list \
| 命令 | 用途 | API 版本 |
|------------------|------------------------------------|----------|
| `+progress-list` | 获取某个目标/关键结果的所有进展记录 | v2 |
| `+progress-list` | 分页获取某个目标/关键结果的进展记录 | v2 |
| `+progress-get` | 根据进展记录 ID 获取单条记录 | v1 |
`+progress-list` 返回的 `progress_list` 数组中每条记录的结构与 `+progress-get` 返回的 `progress` 结构相同。

View File

@@ -17,7 +17,7 @@
<slide>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<content autoFit="normal-auto-fit" wrap="true" textType="title">
<p>标题</p>
</content>
</shape>
@@ -133,7 +133,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
示例:
```xml
<content textType="body" textAlign="left">
<content autoFit="normal-auto-fit" textType="body" textAlign="left">
<p>正文内容 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
<ul>
<li><p>列表项 1</p></li>
@@ -351,7 +351,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
```xml
<note>
<content textType="body">
<content autoFit="normal-auto-fit" textType="body">
<p>这是演讲者备注。</p>
</content>
</note>

View File

@@ -38,6 +38,10 @@ SXSD_ATTR_ALIASES = {
"fontColor": "color",
}
SERVER_FILLED_SXSD_ATTRS = {"id"}
ROUNDTRIP_SXSD_ATTRS = {
("chart", "updated"),
("chartData", "isStaticData"),
}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
@@ -62,7 +66,7 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
while index < len(argv):
token = argv[index]
if not token.startswith("--"):
fail(f"unexpected argument: {token}")
fail(f"unexpected argument: {token}, need --input")
key = token[2:]
next_token = argv[index + 1] if index + 1 < len(argv) else None
if next_token is None or next_token.startswith("--"):
@@ -168,8 +172,10 @@ def solve_weighted_min_layout(
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
def strip_xml(value: str) -> str:
def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
if preserve_line_breaks:
stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
stripped = re.sub(r"<[^>]+>", " ", stripped)
stripped = stripped.replace("&nbsp;", " ")
stripped = stripped.replace("&amp;", "&")
@@ -177,14 +183,46 @@ def strip_xml(value: str) -> str:
stripped = stripped.replace("&gt;", ">")
stripped = stripped.replace("&quot;", '"')
stripped = stripped.replace("&#39;", "'")
if preserve_line_breaks:
return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
return re.sub(r"\s+", " ", stripped).strip()
def strip_xml_paragraphs(value: str) -> str:
paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
if paragraphs:
return "\n".join(strip_xml(paragraph) for paragraph in paragraphs)
return strip_xml(value)
return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
return strip_xml(value, preserve_line_breaks=True)
def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[dict[str, Any]]:
paragraphs = []
for attrs, body in re.findall(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", value):
paragraphs.append(
{
"text": strip_xml(body, preserve_line_breaks=True),
"fontSize": extract_max_span_font_size(body, default_font_size),
"textAlign": extract_attribute(attrs, "textAlign"),
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
}
)
return paragraphs
def extract_max_span_font_size(value: str, default_font_size: int | float) -> int | float:
font_sizes = [
font_size
for attrs in re.findall(r"<span\b([^>]*)>", value)
if (font_size := extract_numeric_attribute(attrs, "fontSize")) is not None
]
return max([default_font_size, *font_sizes])
def extract_tag_attributes(value: str, tag: str) -> str:
match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value)
return match.group(1) if match else ""
def xml_local_name(tag: str) -> str:
@@ -319,8 +357,8 @@ def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
def should_skip_sxsd_attribute(attr_name: str) -> bool:
return attr_name in SERVER_FILLED_SXSD_ATTRS
def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
@@ -352,7 +390,7 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
if raw_attr_name.startswith(XML_NS):
continue
attr_name = xml_local_name(raw_attr_name)
if should_skip_sxsd_attribute(attr_name):
if should_skip_sxsd_attribute(tag_name, attr_name):
continue
if attr_name in allowed_attrs:
continue
@@ -606,6 +644,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
rotation = extract_numeric_attribute(attrs, "rotation") or 0
alpha = extract_numeric_attribute(attrs, "alpha")
table_layouts: dict[str, dict[str, Any] | None] = {}
if kind == "table":
width, table_layouts["width"] = resolve_table_dimension(
@@ -624,6 +663,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
"width": width,
"height": height,
"rotation": rotation,
"alpha": alpha if alpha is not None else 1,
"order": len(elements),
}
if kind == "table":
@@ -635,15 +675,28 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
}
)
if kind == "shape":
content_attrs = extract_tag_attributes(content, "content")
font_size = extract_numeric_attribute(content_attrs, "fontSize")
if font_size is None:
font_size = extract_numeric_attribute(attrs, "fontSize")
element.update(
{
"textType": extract_attribute(content, "textType"),
"textAlign": extract_attribute(content, "textAlign"),
"autoFit": extract_attribute(content, "autoFit"),
"fontSize": float(
extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16
),
"textType": extract_attribute(content_attrs, "textType"),
"textAlign": extract_attribute(content_attrs, "textAlign"),
"verticalAlign": extract_attribute(content_attrs, "verticalAlign") or "middle",
"vert": extract_attribute(attrs, "vert") or "horz",
"autoFit": extract_attribute(content_attrs, "autoFit"),
"wrap": extract_attribute(content_attrs, "wrap"),
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
"fontSize": font_size if font_size is not None else 16,
"text": strip_xml_paragraphs(content),
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
}
)
elements.append(element)
@@ -671,6 +724,40 @@ def has_text_content(element: dict[str, Any]) -> bool:
return bool(element.get("text"))
def is_vertical_text(element: dict[str, Any]) -> bool:
return element.get("vert") in {"vert", "vert270", "word-art-vert", "word-art-vert-rtl", "ea-vert"}
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
for text_element in text_elements:
for image_element in image_elements:
if image_element["order"] <= text_element["order"]:
continue
if is_vertical_text(text_element):
if intersects(image_element, text_element):
issues.append({
"level": "info",
"code": "image_may_cover_vertical_text",
"elements": [image_element["id"], text_element["id"]],
"message": f'image {image_element["id"]} may cover vertical text shape {text_element["id"]}',
"hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
})
continue
text_visual_bbox = estimate_text_visual_bbox(text_element)
if text_visual_bbox is not None and intersects(image_element, text_visual_bbox):
issues.append({
"level": "error",
"code": "image_covers_text",
"elements": [image_element["id"], text_element["id"]],
"message": f'image {image_element["id"]} covers text shape {text_element["id"]}',
"hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
})
return issues
def is_decorative_text(element: dict[str, Any]) -> bool:
text = element.get("text") or ""
return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
@@ -708,27 +795,135 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
def estimate_text_line_count(element: dict[str, Any]) -> int:
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
hard_lines = text.split("\n")
if not text:
return 0
line_count = 0
for paragraph in paragraphs:
logical_width = max(estimate_text_width(paragraph, font_size), 1)
for hard_line in hard_lines:
if element.get("wrap") in {"false", "0"}:
line_count += 1
continue
logical_width = max(estimate_text_width(hard_line, font_size), 1)
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
return max(line_count, 1)
return line_count
def estimate_text_line_count(element: dict[str, Any]) -> int:
return max(estimate_text_line_count_for_text(element, element["text"]), 1)
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
line_spacing = line_spacing or "multiple:1.5"
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return None
spacing_type, value = match.groups()
return font_size * float(value) if spacing_type == "multiple" else float(value)
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
if not is_text_element(element) or not has_text_content(element):
continue
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
continue
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
paragraphs = element.get("paragraphs") or [
{
"text": element["text"],
"lineSpacing": None,
"beforeLineSpacing": None,
"afterLineSpacing": None,
}
]
line_count = 0
estimated_height = 0.0
line_heights: list[int | float] = []
for paragraph in paragraphs:
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
if paragraph_line_count == 0:
continue
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
before_spacing = estimate_text_line_height(
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
)
after_spacing = estimate_text_line_height(
element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0"
)
if line_height is None or before_spacing is None or after_spacing is None:
line_count = 0
break
first_line_height = font_size if line_count == 0 else line_height
line_count += paragraph_line_count
line_heights.append(line_height)
estimated_height += (
before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing
)
if line_count == 0:
continue
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
overflow = estimated_height - available_height
if overflow <= 0:
continue
issues.append(
{
"level": "warning",
"code": "text_may_overflow_shape",
"elements": [element["id"]],
"line_count": line_count,
"line_height": max(line_heights),
"estimated_height": estimated_height,
"available_height": available_height,
"overflow": overflow,
"message": (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
),
"hint": (
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
"autoFit=\"normal-auto-fit\". "
"This is an estimate based on font size, line spacing, and wrapped line count."
),
}
)
return issues
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
return None
padding_left = element.get("paddingLeft", 0)
padding_right = element.get("paddingRight", 0)
padding_top = element.get("paddingTop", 0)
padding_bottom = element.get("paddingBottom", 0)
content_width = max(element["width"] - padding_left - padding_right, 0)
content_height = max(element["height"] - padding_top - padding_bottom, 0)
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
line_count = estimate_text_line_count(element)
visual_width = min(element["width"], max(1, estimate_text_max_line_width(element)))
visual_height = min(element["height"], max(1, line_count * font_size * 1.2))
estimated_width = max(1, estimate_text_max_line_width(element))
visual_width = estimated_width if element.get("wrap") in {"false", "0"} else min(content_width, estimated_width)
visual_height = min(content_height, max(1, line_count * font_size * 1.2))
x = element["x"] + padding_left
if element.get("textAlign") == "center":
x += (content_width - visual_width) / 2
elif element.get("textAlign") == "right":
x += content_width - visual_width
y = element["y"] + padding_top
if element.get("verticalAlign") == "middle":
y += (content_height - visual_height) / 2
elif element.get("verticalAlign") == "bottom":
y += content_height - visual_height
return {
"x": element["x"],
"y": element["y"],
"x": x,
"y": y,
"width": visual_width,
"height": visual_height,
}
@@ -818,6 +1013,10 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
source, target = sorted([left, right], key=lambda element: element["x"])
if source["x"] == target["x"]:
return False
wrap_enabled = source.get("wrap") not in {"false", "0"}
has_horizontal_gap = source["x"] + source["width"] <= target["x"]
if wrap_enabled and has_horizontal_gap:
return False
if source.get("autoFit") == "normal-auto-fit":
return False
if source.get("textAlign") in {"center", "right"}:
@@ -1116,6 +1315,8 @@ def lint_slide(
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
*detect_table_layout_size_mismatches(elements),
*detect_text_may_overflow_shapes(elements),
*detect_image_text_occlusions(elements),
]
for index, left in enumerate(elements):

View File

@@ -34,6 +34,24 @@ class XmlTextOverlapLintTest(unittest.TestCase):
f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
)
def test_cli_suggests_input_flag_for_positional_argument(self) -> None:
script_path = Path(xml_text_overlap_lint.__file__).resolve()
input_path = "/sandboxdata/workspace/file/full_presentation.xml"
completed = subprocess.run(
[sys.executable, str(script_path), input_path],
capture_output=True,
check=False,
text=True,
)
self.assertEqual(completed.returncode, 1)
self.assertEqual(completed.stdout, "")
self.assertEqual(
completed.stderr,
f"xml-text-overlap-lint error: unexpected argument: {input_path}need --input\n",
)
def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
samples = {
"image-led-cover": """
@@ -311,6 +329,42 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(issue["tag"], "fill")
self.assertEqual(issue["attr"], "unexpected")
def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart updated="true" topLeftX="80" topLeftY="80" width="300" height="160">
<chartData isStaticData="true"/>
</chart>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart isStaticData="true" topLeftX="80" topLeftY="80" width="300" height="160">
<chartData updated="true"/>
</chart>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
{(issue["tag"], issue["attr"]) for issue in result["issues"]},
{("chart", "isStaticData"), ("chartData", "updated")},
)
self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in result["issues"]))
def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -468,43 +522,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertIn("iconpark-index.json", issue["hint"])
self.assertIn("iconpark/Base/setting.svg", issue["hint"])
def test_lint_xml_allows_namespaced_svg_inside_whiteboard(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="wb" topLeftX="80" topLeftY="80" width="300" height="180">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 180">
<rect x="10" y="10" width="120" height="60"/>
</svg>
</whiteboard>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_reports_unqualified_svg_inside_whiteboard_with_namespace_hint(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="wb" topLeftX="80" topLeftY="80" width="300" height="180">
<svg viewBox="0 0 300 180">
<rect x="10" y="10" width="120" height="60"/>
</svg>
</whiteboard>
</data>
</slide>
"""
)
issue = result["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issue["code"], "sxsd_unsupported_tag")
self.assertEqual(issue["tag"], "svg")
self.assertIn('xmlns="http://www.w3.org/2000/svg"', issue["hint"])
def test_lint_xml_detects_overlapping_text_boxes(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -523,6 +540,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_detects_current_itinerary_cjk_caption_occlusion(self) -> None:
@@ -589,7 +607,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="source" type="text" topLeftX="80" topLeftY="100" width="160" height="40">
<content fontSize="18"><p>这是一个足够长的中文文本用于检测跨越间隙的横向溢出</p></content>
<content fontSize="18" wrap="false"><p>这是一个足够长的中文文本用于检测跨越间隙的横向溢出</p></content>
</shape>
<shape id="target" type="text" topLeftX="260" topLeftY="100" width="160" height="40">
<content fontSize="18"><p>目标</p></content>
@@ -599,10 +617,31 @@ class XmlTextOverlapLintTest(unittest.TestCase):
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None:
def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="source" type="text" topLeftX="80" topLeftY="100" width="160" height="40">
<content fontSize="18"><p>这是一个足够长的中文文本用于检测默认自动换行</p></content>
</shape>
<shape id="target" type="text" topLeftX="260" topLeftY="100" width="160" height="40">
<content fontSize="18"><p>目标</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -621,10 +660,98 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issue["code"], "shape_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
def test_lint_xml_warns_when_text_may_overflow_its_own_shape(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
<content fontSize="20" lineSpacing="multiple:1.5">
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
<shape id="fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="120">
<content fontSize="20" lineSpacing="multiple:1.5">
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
<shape id="auto-fit" type="text" topLeftX="80" topLeftY="240" width="360" height="80">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="normal-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issues[0]["code"], "text_may_overflow_shape")
self.assertEqual(issues[0]["elements"], ["overflowing"])
self.assertEqual(issues[0]["line_count"], 4)
self.assertEqual(issues[0]["estimated_height"], 110)
self.assertEqual(issues[0]["available_height"], 80)
self.assertEqual(issues[0]["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', issues[0]["message"])
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
<content fontSize="20" lineSpacing="fixed:20">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
</data>
</slide>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issue["line_height"], 20)
self.assertEqual(issue["estimated_height"], 60)
self.assertEqual(issue["overflow"], 10)
def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="35">
<content fontSize="20" lineSpacing="multiple:1.5">
<p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
</content>
</shape>
<shape id="paragraph-fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="40">
<content fontSize="20" lineSpacing="multiple:1.5">
<p lineSpacing="fixed:10">第一行<br/>第二行<br/>第三行</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issues[0]["elements"], ["paragraph-overflow"])
self.assertEqual(issues[0]["line_count"], 2)
self.assertEqual(issues[0]["line_height"], 10)
self.assertEqual(issues[0]["estimated_height"], 40)
self.assertEqual(issues[0]["overflow"], 5)
def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
self.assertEqual(
xml_text_overlap_lint.strip_xml_paragraphs("<p>第一行<br/>第二行<br />第三行</p>"),
"第一行\n第二行\n第三行",
)
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -730,7 +857,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
<shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
<img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
<chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
<whiteboard id="outside-whiteboard" topLeftX="100" topLeftY="500" width="100" height="100"/>
</data>
</slide>
</presentation>
@@ -1054,89 +1180,6 @@ class XmlTextOverlapLintTest(unittest.TestCase):
result["slides"][0]["issues"],
)
def test_lint_xml_warns_for_whiteboard_external_boundary_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="wb" topLeftX="50" topLeftY="140" width="860" height="360"/>
<shape id="card" type="rect" topLeftX="50" topLeftY="450" width="200" height="60">
<fill><fillColor color="rgba(232, 186, 176, 0.2)"/></fill>
<content/>
</shape>
<shape id="label" type="text" topLeftX="65" topLeftY="460" width="170" height="20">
<content fontSize="12"><p>基础保障</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issues[0]["code"], "whiteboard_external_overlap")
self.assertIn("static whiteboard container-bbox risk", issues[0]["hint"])
self.assertIn("screenshot QA", issues[0]["hint"])
self.assertEqual(issues[0]["elements"], ["wb", "card"])
self.assertEqual(issues[0]["overlaps"][0]["overlap_height"], 50)
def test_lint_xml_allows_whiteboard_internal_labels(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="wb" topLeftX="100" topLeftY="120" width="420" height="260"/>
<shape id="label" type="text" topLeftX="150" topLeftY="180" width="180" height="40">
<content fontSize="14"><p>内部标注</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_allows_bottom_layer_full_slide_whiteboard_background(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="background" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="title" type="text" topLeftX="80" topLeftY="80" width="420" height="80">
<content textType="title" fontSize="36"><p>Title</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_allows_whiteboard_inside_background_panel(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="panel" type="rect" topLeftX="40" topLeftY="100" width="880" height="340">
<fill><fillColor color="rgba(232, 186, 176, 0.2)"/></fill>
<content/>
</shape>
<whiteboard id="wb" topLeftX="60" topLeftY="120" width="840" height="300"/>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_detects_invalid_template_text_stack_overlap(self) -> None:
cases = [
(
@@ -1166,5 +1209,21 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="text" type="text" vert="vert" topLeftX="100" topLeftY="100" width="100" height="100">
<content><p>Vertical</p></content>
</shape>
<img id="image" src="token" topLeftX="120" topLeftY="120" width="20" height="20"/>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
self.assertEqual(issue["level"], "info")
self.assertEqual(result["summary"]["error_count"], 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -22,21 +22,22 @@ metadata:
**身份**:画板操作默认使用 `--as user`。仅当需要以应用身份上传时使用 `--as bot`
| 用户需求 | 行动 |
|-----------------------------------------|-----------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 / 导出 SVG 矢量图 | [`+query --output_as image/svg`](references/lark-whiteboard-query.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 仅微调节点文字/颜色 | `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
| 用户需求 | 行动 |
|-----------------------------------------|---------------------------------------------------------------------------------------------------|
| 查看画板内容 / 导出图片 | [`+export --output-type preview`](references/lark-whiteboard-export.md) |
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 检查画板是否由代码绘制 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 仅微调节点文字/颜色 | `+export --output-type raw` → 手动改 JSON → `+update --input_format raw` |
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
## Shortcuts
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、SVG 矢量图、代码或原始节点结构。 |
| Shortcut | 说明 |
|---------------------------------------------------|---|
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
---

View File

@@ -1,23 +1,23 @@
# whiteboard +query查询画板)
# whiteboard +export导出画板)
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询画板内容支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
导出画板内容支持导出为预览图片、SVG 矢量图、提取 PlantUML/Mermaid 代码,或获取飞书 OpenAPI 原生画板节点格式。
## 参数
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token需要拥有画板的读权限 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`SVG 矢量图)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as svg/code/raw` 时可选,不填则直接输出到终端 |
| `--output-type` | 是 | 输出格式:`preview`(预览图片)、`svg`SVG 矢量图)、`source`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output-type preview` 时必填,推荐传入无后缀文件路径(如 `./preview`);当 `--output-type svg/source/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `image`:预览图片
- `preview`:预览图片。推荐 `--output ./preview` 这类无后缀文件路径CLI 会按实际图片类型保存为 `./preview.png``./preview.jpg`。如果 `--output` 是目录,会保存为该目录下的 `whiteboard_<whiteboard-token>.png/.jpg`;如果显式写了后缀,需要和实际图片类型匹配。`--overwrite` 检查的是补齐后缀后的最终路径,例如返回 PNG 时 `--output ./preview` 对应覆盖 `./preview.png`
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `code`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `source`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
## 示例
@@ -25,26 +25,26 @@
### 示例 1导出画板为预览图片
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as image \
--output ./preview.png
--output-type preview \
--output ./preview
```
### 示例 2提取画板中的代码并直接输出
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as code
--output-type source
```
### 示例 3导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as svg \
--output-type svg \
--output ./whiteboard.svg \
--as user
```
@@ -52,9 +52,9 @@ lark-cli whiteboard +query \
### 示例 4导出画板原始节点结构到文件
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as raw \
--output-type raw \
--output ./nodes.json \
--overwrite
```

View File

@@ -26,12 +26,12 @@
**Step 2判断修改策略**
```
+query --output_as code
+export --output-type source
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码SVG/DSL 或其他方式绘制的画板)
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
│ │ → +query --output_as image → 看图 → +query --output_as raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
│ → [`../routes/svg-edit.md`](../routes/svg-edit.md)(视觉高保真还原,大部分场景适用)
└─ 用户有明确要求 → 以用户要求优先

View File

@@ -25,9 +25,9 @@ SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +query \
lark-cli whiteboard +export \
--whiteboard-token <TOKEN> \
--output_as svg \
--output-type svg \
--output <dir>/original.svg \
--as user
```

View File

@@ -54,8 +54,6 @@
- 阴影:`<filter>` 里放 `<feDropShadow>` 或标准 drop/inner primitive 链 (`<feGaussianBlur in="SourceAlpha">` + `<feOffset>` + `<feFlood>` + `<feComposite>` + `<feMerge>`), 会被识别成节点阴影, drop 至多 1 个, inner 至多 1 个; 其余 filter 效果不识别
- 渐变:`<linearGradient>` / `<radialGradient>``<defs>` 中定义, 通过 `fill="url(#id)"` 引用 (载体限 `<rect>` / `<circle>` / `<ellipse>` / `<polygon>` / `<path>`), 需要至少 2 个 `<stop>`, `gradientUnits` 只支持默认的 `objectBoundingBox` (不写即可);
> [!IMPORTANT]
> ⚠️ **不支持的装饰特性**
**⚠️ [!IMPORTANT] 不支持的装饰特性**
- `<pattern>` / `<clipPath>` / `<mask>` / 非阴影用途的 `<filter>` (blur / hue-rotate / 复合合成 / `flood-color=url(...)` / 多个 `<feDropShadow>` 等) → 画板不支持,**请避免使用,否则会导致画板渲染问题**
- 渐变边界:`gradientUnits="userSpaceOnUse"` / `spreadMethod="reflect|repeat"` / stops 少于 2 个 / 复杂 `gradientTransform` 会变成不可编辑图片, 视觉正确但失去可编辑性, 若无必要请沿用默认 `objectBoundingBox`

View File

@@ -24,6 +24,7 @@ metadata:
## 快速决策
- 用户要**按特定主题 / 关键词 / 内容线索查找资料并收集到知识库节点或新建知识库节点下**,必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](../lark-drive/references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 使用 Drive 全量搜索召回,再按 Wiki 目标解析、确认和移动;不要只用 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 / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseFieldUpdateAutoNumberDryRun(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+field-update",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--field-id", "fld_x",
"--json", `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
"--yes",
)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out)
require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "编号", gjson.Get(out, "data.api.0.body.name").String(), out)
require.Equal(t, "auto_number", gjson.Get(out, "data.api.0.body.type").String(), out)
require.Equal(t, "created_time", gjson.Get(out, "data.api.0.body.style.rules.1.type").String(), out)
require.Equal(t, "yyyyMM", gjson.Get(out, "data.api.0.body.style.rules.1.date_format").String(), out)
require.Equal(t, int64(4), gjson.Get(out, "data.api.0.body.style.rules.3.length").Int(), out)
require.False(t, gjson.Get(out, "data.api.0.body.property.auto_serial").Exists(), out)
require.NotContains(t, out, "reformat_existing_records", out)
require.NotContains(t, out, "/open-apis/bitable/v1/", out)
}
func TestBaseFieldUpdateDryRunAllowsRatingMaxAboveLimit(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+field-update",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--field-id", "fld_x",
"--json", `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`,
"--yes",
)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out)
require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "评分", gjson.Get(out, "data.api.0.body.name").String(), out)
require.Equal(t, "rating", gjson.Get(out, "data.api.0.body.style.type").String(), out)
require.Equal(t, int64(20), gjson.Get(out, "data.api.0.body.style.max").Int(), out)
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestBaseRecordBatchUpdatePerRecordDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-update",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "Done", clie2e.DryRunGet(out, "api.0.body.update_records.recA.Status.0").String(), out)
require.Equal(t, int64(20), clie2e.DryRunGet(out, "api.0.body.update_records.recB.Score").Int(), out)
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"encoding/json"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-batch-update-"+clie2e.GenerateSuffix())
tableID, _, _ := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
"Batch Update "+clie2e.GenerateSuffix(),
`[{"name":"Name","type":"text"},{"name":"Status","type":"select","multiple":false,"options":[{"name":"Open"},{"name":"Done"}]},{"name":"Score","type":"number"}]`,
`{"name":"Main","type":"grid"}`,
)
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-create",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`,
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
firstRecordID := gjson.Get(createResult.Stdout, "data.record_id_list.0").String()
secondRecordID := gjson.Get(createResult.Stdout, "data.record_id_list.1").String()
require.NotEmpty(t, firstRecordID, "stdout:\n%s", createResult.Stdout)
require.NotEmpty(t, secondRecordID, "stdout:\n%s", createResult.Stdout)
updateBody, err := json.Marshal(map[string]map[string]map[string]any{
"update_records": {
firstRecordID: {"Status": []string{"Done"}},
secondRecordID: {"Score": 20},
},
})
require.NoError(t, err)
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-update",
"--base-token", baseToken,
"--table-id", tableID,
"--json", string(updateBody),
},
DefaultAs: "bot",
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
updateResult.AssertStdoutStatus(t, true)
assertRecordFields := func(recordID, expectedStatus string, expectedScore int64) {
t.Helper()
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-get",
"--base-token", baseToken,
"--table-id", tableID,
"--record-id", recordID,
"--field-id", "Status",
"--field-id", "Score",
"--format", "json",
},
DefaultAs: "bot",
})
require.NoError(t, runErr)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, recordID, gjson.Get(result.Stdout, "data.record_id_list.0").String(), result.Stdout)
require.Equal(t, "Status", gjson.Get(result.Stdout, "data.fields.0").String(), result.Stdout)
require.Equal(t, "Score", gjson.Get(result.Stdout, "data.fields.1").String(), result.Stdout)
require.Equal(t, expectedStatus, gjson.Get(result.Stdout, "data.data.0.0.0").String(), result.Stdout)
require.Equal(t, expectedScore, gjson.Get(result.Stdout, "data.data.0.1").Int(), result.Stdout)
}
assertRecordFields(firstRecordID, "Done", 10)
assertRecordFields(secondRecordID, "Open", 20)
missingResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-update",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"update_records":{"recZZZZZZZZZZZ":{"Name":"missing-record"}}}`,
},
DefaultAs: "bot",
})
require.NoError(t, err)
missingResult.AssertExitCode(t, 0)
missingResult.AssertStdoutStatus(t, true)
}

View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseRecordListDryRunAcceptsFieldsAlias(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-list",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--fields", `["Name","Age"]`,
"--limit", "3",
)
out := result.Stdout
require.Equal(t, "GET", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=Name&field_id=Age&limit=3&offset=0", gjson.Get(out, "data.api.0.url").String(), out)
}
func TestBaseRecordSearchDryRunAcceptsFieldsAlias(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--keyword", "Alice",
"--search-field", "Name",
"--fields", `["Name","Age"]`,
)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search", gjson.Get(out, "data.api.0.url").String(), out)
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
require.Equal(t, "Age", gjson.Get(out, "data.api.0.body.select_fields.1").String(), out)
}
func TestBaseRecordGetDryRunAcceptsFieldNamesAlias(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-get",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_1",
"--field-names", "Name",
"--field-names", "Age",
)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
require.Equal(t, "Age", gjson.Get(out, "data.api.0.body.select_fields.1").String(), out)
}
func TestBaseRecordGetDryRunTreatsNullProjectionAsOmitted(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-get",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"record_id_list":["rec_1"],"select_fields":null}`,
)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
require.False(t, gjson.Get(out, "data.api.0.body.select_fields").Exists(), out)
}
func TestBaseRecordGetDryRunUsesFlagProjectionWhenJSONProjectionIsNull(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-get",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"record_id_list":["rec_1"],"select_fields":null}`,
"--field-id", "Name",
)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", gjson.Get(out, "data.api.0.url").String(), out)
require.Equal(t, "rec_1", gjson.Get(out, "data.api.0.body.record_id_list.0").String(), out)
require.Equal(t, "Name", gjson.Get(out, "data.api.0.body.select_fields.0").String(), out)
}
func TestBaseRecordListDryRunPreservesFieldNamesCSVSemantics(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-list",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--field-names", `"A,B",@Owner`,
"--limit", "3",
)
out := result.Stdout
require.Equal(t, "GET", gjson.Get(out, "data.api.0.method").String(), out)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=A%2CB&field_id=%40Owner&limit=3&offset=0", gjson.Get(out, "data.api.0.url").String(), out)
}
func TestBaseRecordListDryRunTreatsLeadingAtFieldNameLiterally(t *testing.T) {
result := runBaseDryRun(t, 0,
"base", "+record-list",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--field-names", "@Owner",
"--limit", "3",
)
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records?field_id=%40Owner&limit=3&offset=0", gjson.Get(result.Stdout, "data.api.0.url").String(), result.Stdout)
}
func TestBaseRecordSearchDryRunJSONConflictReportsActualParams(t *testing.T) {
result := runBaseDryRun(t, 2,
"base", "+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
"--field-names", "Age",
)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--json", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Equal(t, int64(2), gjson.Get(result.Stderr, "error.params.#").Int(), result.Stderr)
require.Equal(t, "--json", gjson.Get(result.Stderr, "error.params.0.name").String(), result.Stderr)
require.Equal(t, "--field-names", gjson.Get(result.Stderr, "error.params.1.name").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.hint").String(), "inside --json")
require.Empty(t, result.Stdout)
}
func TestBaseRecordProjectionDryRunKeepsActiveParamForFlagLikeFieldNames(t *testing.T) {
tests := []struct {
name string
args []string
wantParam string
}{
{
name: "canonical",
args: []string{
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
"--field-id", "Cost--USD", "--field-id", "Cost--USD",
},
wantParam: "--field-id",
},
{
name: "fields alias",
args: []string{
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
"--fields", `["Cost--USD","Cost--USD"]`,
},
wantParam: "--fields",
},
{
name: "field names alias",
args: []string{
"base", "+record-list", "--base-token", "app_x", "--table-id", "tbl_x",
"--field-names", "Cost--USD", "--field-names", "Cost--USD",
},
wantParam: "--field-names",
},
{
name: "json projection",
args: []string{
"base", "+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
"--json", `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
},
wantParam: "--json",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := runBaseDryRun(t, 2, tc.args...)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Equal(t, int64(1), gjson.Get(result.Stderr, "error.params.#").Int(), result.Stderr)
require.Equal(t, tc.wantParam, gjson.Get(result.Stderr, "error.params.0.name").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), "duplicate field id")
require.Empty(t, result.Stdout)
})
}
}

View File

@@ -2,16 +2,18 @@
## Metrics
- Denominator: 78 leaf commands
- Covered: 19
- Coverage: 24.4%
- Covered: 22
- Coverage: 28.2%
## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
## Command Table
@@ -54,10 +56,10 @@
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| | base +record-batch-create | shortcut | | none | record workflows not covered |
| | base +record-batch-update | shortcut | | none | record workflows not covered |
| | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.fields`; `--json.rows` | seeds heterogeneous live workflow records |
| | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
| | base +record-get | shortcut | | none | record workflows not covered |
| | base +record-get | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--record-id`; repeated `--field-id`; `--format json` | reads back select and number values after batch update |
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |

View File

@@ -18,6 +18,21 @@ import (
const cleanupTimeout = 30 * time.Second
func runBaseDryRun(t *testing.T, wantExitCode int, args ...string) *clie2e.Result {
t.Helper()
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
requestArgs := append([]string(nil), args...)
requestArgs = append(requestArgs, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: requestArgs, DefaultAs: "user"})
require.NoError(t, err)
result.AssertExitCode(t, wantExitCode)
return result
}
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
parentT.Helper()

View File

@@ -88,12 +88,6 @@ func SkipWithoutTenantAccessToken(t *testing.T) {
if token == "" || appID == "" {
t.Skip("skipped: tenant test credentials not set")
}
// Scope standard env credentials to tests that explicitly require a live
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
// config and dry-run CLI subprocesses from activating the env provider.
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
}
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
@@ -245,14 +239,36 @@ func buildCommandEnv(req Request) []string {
for k, v := range req.Env {
overrides[k] = v
}
// Keep user-token injection scoped to user-only test commands so bot
// commands retain the process-level bot credentials.
if req.DefaultAs == "user" {
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
// Shared TEST_* credentials are fallbacks for explicitly identified live
// commands. Existing standard env (including dry-run fixtures) and
// per-request overrides always take precedence.
switch req.DefaultAs {
case "bot":
if !hasCredentialEnv(req.Env,
"LARKSUITE_CLI_APP_ID",
"LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN",
) {
appID := os.Getenv("TEST_BOT1_APP_ID")
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
if appID != "" && token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_TENANT_ACCESS_TOKEN"] = token
}
}
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
case "user":
if !hasCredentialEnv(req.Env,
"LARKSUITE_CLI_APP_ID",
"LARKSUITE_CLI_APP_SECRET",
"LARKSUITE_CLI_USER_ACCESS_TOKEN",
) {
appID := os.Getenv("TEST_BOT1_APP_ID")
token := os.Getenv("TEST_USER_ACCESS_TOKEN")
if appID != "" && token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
}
}
}
for k, v := range overrides {
@@ -272,6 +288,18 @@ func buildCommandEnv(req Request) []string {
return env
}
func hasCredentialEnv(requestEnv map[string]string, keys ...string) bool {
for _, key := range keys {
if _, ok := requestEnv[key]; ok {
return true
}
if os.Getenv(key) != "" {
return true
}
}
return false
}
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
if opts.Attempts <= 0 {

View File

@@ -190,7 +190,7 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
assert.True(t, ran)
})
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
t.Run("accepts shared tenant credentials without mutating standard env", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
t.Setenv("LARKSUITE_CLI_APP_ID", "")
@@ -198,8 +198,8 @@ func TestSkipWithoutTenantAccessToken(t *testing.T) {
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutTenantAccessToken(t)
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
})
require.True(t, ok)
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
@@ -274,25 +274,65 @@ func TestRunCmd(t *testing.T) {
assert.Equal(t, "hello from stdin\n", result.Stdout)
})
t.Run("injects user token env only for user commands", func(t *testing.T) {
t.Run("injects shared credentials by requested identity", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "tat_test")
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
env := buildCommandEnv(Request{DefaultAs: "user"})
env := buildCommandEnv(Request{DefaultAs: "bot"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "user"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.Contains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{DefaultAs: "bot"})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
env = buildCommandEnv(Request{})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=tat_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("preserves standard dry-run bot credentials", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "dry-run-app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "dry-run-secret")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
env := buildCommandEnv(Request{DefaultAs: "bot"})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=dry-run-app")
assert.Contains(t, env, "LARKSUITE_CLI_APP_SECRET=dry-run-secret")
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
})
t.Run("request env overrides shared bot credentials", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
env := buildCommandEnv(Request{
DefaultAs: "bot",
Env: map[string]string{
"LARKSUITE_CLI_APP_ID": "request-app",
"LARKSUITE_CLI_TENANT_ACCESS_TOKEN": "",
},
})
assert.Contains(t, env, "LARKSUITE_CLI_APP_ID=request-app")
assert.Contains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=")
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=shared-test-app")
assert.NotContains(t, env, "LARKSUITE_CLI_TENANT_ACCESS_TOKEN=shared-test-token")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {
fake := newFakeCLI(t)
statePath := filepath.Join(t.TempDir(), "retry-count")

View File

@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "block_delete",
"--block-id", "blkA,blkB,blkC",
"--block-id", "blkA, blkB, blkC",
"--dry-run",
},
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
},
{
name: "history list",
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
require.Equal(t, "<title>Dry Run &amp; Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
}
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--title", "Flag title",
"--content", "<title>Content title</title><p>body</p>",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
}
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
want string
}{
{
name: "multiline XML str_replace",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
want: "must be inline",
},
{
name: "duplicate block delete ID",
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
want: "duplicate ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
})
}
}

View File

@@ -35,6 +35,8 @@ func TestOKR_CycleListDryRun(t *testing.T) {
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
assert.True(t, strings.Contains(output, "ou_dryrun_test"), "dry-run should contain user-id, got: %s", output)
assert.Equal(t, int64(100), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain default page_size=100, got: %s", output)
assert.False(t, clie2e.DryRunGet(output, "api.0.params.page_token").Exists(), "empty page_token should be omitted, got: %s", output)
}
// TestOKR_CycleListDryRun_WithTimeRange validates +cycle-list dry-run with --time-range flag.
@@ -57,3 +59,26 @@ func TestOKR_CycleListDryRun_WithTimeRange(t *testing.T) {
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
}
// TestOKR_CycleListDryRun_WithPagination validates +cycle-list dry-run with explicit pagination.
func TestOKR_CycleListDryRun_WithPagination(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-list",
"--user-id", "ou_dryrun_test",
"--page-size", "20",
"--page-token", "next_page",
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.Equal(t, int64(20), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain page_size=20, got: %s", output)
assert.Equal(t, "next_page", clie2e.DryRunGet(output, "api.0.params.page_token").String(), "dry-run should contain page_token, got: %s", output)
}

View File

@@ -298,6 +298,8 @@ func TestOKR_ProgressListDryRun_Objective(t *testing.T) {
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/123456789/progresses"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output)
assert.Equal(t, int64(100), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain default page_size=100, got: %s", output)
assert.False(t, clie2e.DryRunGet(output, "api.0.params.page_token").Exists(), "empty page_token should be omitted, got: %s", output)
}
// TestOKR_ProgressListDryRun_KeyResult validates +progress-list dry-run for key_result.
@@ -321,3 +323,27 @@ func TestOKR_ProgressListDryRun_KeyResult(t *testing.T) {
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/key_results/987654321/progresses"), "dry-run should contain key_result API path, got: %s", output)
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output)
}
// TestOKR_ProgressListDryRun_WithPagination validates +progress-list dry-run with explicit pagination.
func TestOKR_ProgressListDryRun_WithPagination(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+progress-list",
"--target-id", "123456789",
"--target-type", "objective",
"--page-size", "25",
"--page-token", "next_page",
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.Equal(t, int64(25), clie2e.DryRunGet(output, "api.0.params.page_size").Int(), "dry-run should contain page_size=25, got: %s", output)
assert.Equal(t, "next_page", clie2e.DryRunGet(output, "api.0.params.page_token").String(), "dry-run should contain page_token, got: %s", output)
}

View File

@@ -18,7 +18,62 @@ import (
"github.com/tidwall/gjson"
)
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
// --- Dry-run E2E tests for +create, +batch-create, +reorder, +weight ---
// TestOKR_CreateDryRun_Objective validates +create dry-run for objective creation.
func TestOKR_CreateDryRun_Objective(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+create",
"--level", "objective",
"--cycle-id", "123456",
"--content", `{"text":"Objective 1","mention":["ou_123"]}`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.Equal(t, "POST", gjson.Get(output, "data.api.0.method").String(), "dry-run should contain POST method")
assert.Equal(t, "/open-apis/okr/v2/cycles/123456/objectives", gjson.Get(output, "data.api.0.url").String(), "dry-run should contain objective API path")
assert.Equal(t, "123456", gjson.Get(output, "data.api.0.params.cycle_id").String(), "dry-run should contain cycle-id query param")
assert.Equal(t, "open_id", gjson.Get(output, "data.api.0.params.user_id_type").String(), "dry-run should contain default user-id-type")
assert.Equal(t, "Objective 1", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(), "dry-run should contain serialized content text")
assert.Equal(t, "ou_123", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(), "dry-run should contain serialized mention")
}
// TestOKR_CreateDryRun_KeyResult validates +create dry-run for key-result creation.
func TestOKR_CreateDryRun_KeyResult(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+create",
"--level", "key-result",
"--objective-id", "789",
"--style", "richtext",
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"KR 1"}}]}}]}`,
"--user-id-type", "user_id",
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.Equal(t, "POST", gjson.Get(output, "data.api.0.method").String(), "dry-run should contain POST method")
assert.Equal(t, "/open-apis/okr/v2/objectives/789/key_results", gjson.Get(output, "data.api.0.url").String(), "dry-run should contain key-result API path")
assert.Equal(t, "789", gjson.Get(output, "data.api.0.params.objective_id").String(), "dry-run should contain objective-id query param")
assert.Equal(t, "user_id", gjson.Get(output, "data.api.0.params.user_id_type").String(), "dry-run should contain explicit user-id-type")
assert.Equal(t, "KR 1", gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(), "dry-run should contain rich-text body")
}
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
func TestOKR_BatchCreateDryRun(t *testing.T) {
@@ -383,6 +438,44 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
}
}
func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suffix string) liveTestCreated {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+create",
"--level", "objective",
"--cycle-id", cycleID,
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
},
})
require.NoError(t, err, "failed to create live objective")
result.AssertExitCode(t, 0)
objectiveID := gjson.Get(result.Stdout, "data.objective_id").String()
require.NotEmpty(t, objectiveID, "objective_id should not be empty")
return liveTestCreated{ObjectiveID: objectiveID}
}
func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string, suffix string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+create",
"--level", "key-result",
"--objective-id", objectiveID,
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
},
})
require.NoError(t, err, "failed to create live key result")
result.AssertExitCode(t, 0)
keyResultID := gjson.Get(result.Stdout, "data.key_result_id").String()
require.NotEmpty(t, keyResultID, "key_result_id should not be empty")
return keyResultID
}
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
func TestOKR_BatchCreateLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
@@ -432,6 +525,87 @@ func TestOKR_BatchCreateLive(t *testing.T) {
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
}
// TestOKR_CreateLive_Objective validates +create objective with real API calls: create, verify, cleanup.
func TestOKR_CreateLive_Objective(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
created := createLiveObjective(t, ctx, cycleID, suffix)
t.Cleanup(func() {
cleanupLiveTest(t, []liveTestCreated{created})
})
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
found := false
for _, obj := range objectives {
if obj.Get("id").String() == created.ObjectiveID {
found = true
break
}
}
assert.True(t, found, "created objective should be visible in cycle detail")
}
// TestOKR_CreateLive_KeyResultUnderExistingObjective validates +create key-result under an existing objective.
func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
created := createLiveObjective(t, ctx, cycleID, suffix)
t.Cleanup(func() {
cleanupLiveTest(t, []liveTestCreated{created})
})
keyResultID := createLiveKeyResult(t, ctx, created.ObjectiveID, suffix)
created.KRIDs = append(created.KRIDs, keyResultID)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
foundObjective := false
foundKR := false
for _, obj := range objectives {
if obj.Get("id").String() != created.ObjectiveID {
continue
}
foundObjective = true
for _, kr := range obj.Get("key_results").Array() {
if kr.Get("id").String() == keyResultID {
foundKR = true
break
}
}
break
}
assert.True(t, foundObjective, "created objective should be visible in cycle detail")
assert.True(t, foundKR, "created key result should be visible under the created objective")
}
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
func TestOKR_ReorderLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestWhiteboardExportDryRun_RequestShapes(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
wantMethod string
wantSuffix string
wantBody map[string]string
}{
{
name: "preview",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunPreview",
"--output-type", "preview",
"--output", "preview",
"--dry-run",
},
wantMethod: "GET",
wantSuffix: "/download_as_image",
},
{
name: "svg",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunSvg",
"--output-type", "svg",
"--dry-run",
},
wantMethod: "POST",
wantSuffix: "/export",
wantBody: map[string]string{
"export_type": "svg",
},
},
{
name: "source",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunSource",
"--output-type", "source",
"--dry-run",
},
wantMethod: "GET",
wantSuffix: "/nodes",
},
{
name: "raw",
args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnDryRunRaw",
"--output-type", "raw",
"--dry-run",
},
wantMethod: "GET",
wantSuffix: "/nodes",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 1 {
t.Fatalf("api count=%d, want 1\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != tt.wantMethod {
t.Fatalf("method=%q, want %q\nstdout:\n%s", got, tt.wantMethod, out)
}
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, tt.wantSuffix) {
t.Fatalf("url=%q, want board whiteboard URL ending %q\nstdout:\n%s", gotURL, tt.wantSuffix, out)
}
for key, want := range tt.wantBody {
if got := clie2e.DryRunGet(out, "api.0.body."+key).String(); got != want {
t.Fatalf("body.%s=%q, want %q\nstdout:\n%s", key, got, want, out)
}
}
})
}
}
func TestWhiteboardQueryDryRun_LegacySmoke(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+query",
"--whiteboard-token", "wbcnDryRunLegacy",
"--output_as", "image",
"--output", "preview",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
}
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/download_as_image") {
t.Fatalf("url=%q, want preview download\nstdout:\n%s", gotURL, out)
}
}
func TestWhiteboardExportSelectorRequiredBeforeAuth(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("export requires output-type", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+export",
"--whiteboard-token", "wbcnMissingSelector",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
output := result.Stdout + "\n" + result.Stderr
if got := gjson.Get(output, "error.type").String(); got != "validation" {
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output-type") {
t.Fatalf("error.message=%q, want output-type\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
})
t.Run("legacy query requires output_as", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+query",
"--whiteboard-token", "wbcnMissingSelector",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
output := result.Stdout + "\n" + result.Stderr
if got := gjson.Get(output, "error.type").String(); got != "validation" {
t.Fatalf("error.type=%q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
if got := gjson.Get(output, "error.message").String(); !strings.Contains(got, "output_as") {
t.Fatalf("error.message=%q, want output_as\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
}
})
}
func setWhiteboardDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "whiteboard_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "whiteboard_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardExportPreview_JPEGLiveWorkflow(t *testing.T) {
token := os.Getenv("LARK_WHITEBOARD_E2E_TOKEN")
if token == "" {
t.Skip("skipped: LARK_WHITEBOARD_E2E_TOKEN not set")
}
clie2e.SkipWithoutUserToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
t.Cleanup(cancel)
workDir := t.TempDir()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+export",
"--whiteboard-token", token,
"--output-type", "preview",
"--output", "preview",
"--overwrite",
},
DefaultAs: "user",
Format: "json",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
saved := filepath.Join(workDir, "preview.jpg")
data, err := os.ReadFile(saved)
require.NoError(t, err, "expected JPEG preview at %s\nstdout:\n%s\nstderr:\n%s", saved, result.Stdout, result.Stderr)
require.True(t, isJPEG(data), "expected JPEG data in %s", saved)
mismatch, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+export",
"--whiteboard-token", token,
"--output-type", "preview",
"--output", "preview.png",
"--overwrite",
},
DefaultAs: "user",
Format: "json",
WorkDir: workDir,
})
require.NoError(t, err)
mismatch.AssertExitCode(t, 2)
if !strings.Contains(mismatch.Stdout+"\n"+mismatch.Stderr, "failed_precondition") {
t.Fatalf("expected failed_precondition for mismatched extension\nstdout:\n%s\nstderr:\n%s", mismatch.Stdout, mismatch.Stderr)
}
}
func isJPEG(data []byte) bool {
return len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff
}