Compare commits

..

20 Commits

Author SHA1 Message Date
zhanghuanxu
6776eec77f docs: remove lark-slides chart critical 2026-07-08 22:00:42 +08:00
zhanghuanxu
59a4d4a962 fix: limit slides screenshot page requests 2026-07-08 22:00:42 +08:00
zhanghuanxu
8db2f49541 docs: prefer slides xml-get shortcut 2026-07-08 22:00:42 +08:00
zhanghuanxu
a6ce6f8db2 feat:edit ppt template 2026-07-08 14:49:32 +08:00
zhanghuanxu
5dd6d75977 docs: require native charts in slide planning 2026-07-08 14:49:32 +08:00
zhanghuanxu
ba20f354ed feat:simplify quick-ref 2026-07-08 14:49:32 +08:00
zhanghuanxu
0db6fe4b4d docs: merge lark slides xml reference docs 2026-07-08 14:49:32 +08:00
zhanghuanxu
bca66cb620 feat(slides): add slides chart demo reference 2026-07-08 14:49:32 +08:00
zhanghuanxu
ef9f397423 feat:slide style 2026-07-08 14:49:32 +08:00
Zee Zheng
869a259d4e docs: clarify success envelope contract — judge success by ok, not code (#1730)
The stdout success envelope is {ok, identity, data, meta?} and carries no
top-level code/msg field, but wrappers following the raw OpenAPI convention
test code == 0 and misclassify every successful call as a failure. Around
write commands this defeats caller-side idempotency and causes duplicate
creates (see #1689).

Document the contract in ERROR_CONTRACT.md (success-envelope counterpart to
the error wire format), both READMEs (JSON Output Contract section), the
lark-shared skill (shared prerequisite for all skills), and add a success
response example to lark-task-create.md.

Closes #1689
2026-07-06 20:03:05 +08:00
fangshuyu-768
ee46e22abd docs: tighten doc creation validation workflow (#1759) 2026-07-06 17:07:10 +08:00
hhang
b76dc18c2f fix(calendar): guide approval room booking fallback (#1637) 2026-07-06 15:04:50 +08:00
calendar-assistant
85679d4258 feat: support semantic recurring calendar operations (#1723) 2026-07-06 14:10:57 +08:00
wangweiming-01
1ba4f3973c fix: guide drive import concurrency conflicts (#1751) 2026-07-06 10:46:46 +08:00
liangshuo-1
c45ff569c4 chore: release v1.0.65 (#1742) 2026-07-03 20:25:22 +08:00
caojie0621
a1506cdffb feat: add docs history shortcuts (#1612)
Add docs +history-list, +history-revert, and +history-revert-status backed by docs_ai history OpenAPI endpoints.

Document the safe history workflow and extend dry-run/live E2E coverage for the new shortcuts.
2026-07-03 16:21:18 +08:00
liuxin-0319
3595356ea1 chore: sync lark-doc skill from online-doc (#1701) 2026-07-03 15:55:46 +08:00
zhangjun-bytedance
73be1d06ec bugfix 0702 about speaker replace (#1731) 2026-07-03 14:05:48 +08:00
liujinkun2025
cccf025599 docs(drive): document 30-char query limit for +search (#1560)
The Search v2 API rejects queries longer than 30 characters (counted by
Unicode code point, CJK 1 each) with 99992402 field validation failed —
it is a hard error, not truncation. Surface this in the --query -h help
text and the lark-drive search skill so callers compress long queries
before searching instead of hitting the error.

Change-Id: Ieb30a66edae7a573690c49719627ec8fb2500a1a
2026-07-03 11:39:07 +08:00
zhanglei-1103
7db899db01 docs: add mindnote guidance to lark-doc (#1581) 2026-07-03 10:50:25 +08:00
76 changed files with 2559 additions and 1515 deletions

3
.gitignore vendored
View File

@@ -27,6 +27,9 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/

View File

@@ -2,6 +2,22 @@
All notable changes to this project will be documented in this file.
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
@@ -1355,6 +1371,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61

View File

@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -20,13 +20,28 @@ import (
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})

View File

@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories
| Category | When | Exit | Typed struct |

View File

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

View File

@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil
}
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
return true
}
}
return false
}
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
if err == nil || !attendeesIncludeRoom(attendees) {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
return err
}
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
}
var CalendarCreate = common.Shortcut{
Service: "calendar",
Command: "+create",
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
"need_notification": true,
})
if err != nil {
err = guideApprovalRoomReasonError(err, attendees)
// Rollback: delete the event
_, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),

View File

@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
}
}
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
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_approval_room",
"summary": "Approval Room",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_approval_room/attendees",
Body: map[string]interface{}{
"code": codeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_approval_room",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Approval Room",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "omm_room1",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for approval room missing approval_reason, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for %T", err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
if p.Code != codeInvalidParamsWithDetail {
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
}
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
}
}
}
// When the add-attendees call fails AND the rollback DELETE also fails, the
// primary error stays the add failure (classification preserved) and the Hint
// must surface BOTH the rollback failure reason and the orphan event_id so the

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update":
return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default:
return docsSkillReadCommand
}
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help"
case "update":
return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default:
return "lark-cli docs --help"
}
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert,
DocMediaUpload,
DocMediaPreview,

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
@@ -28,6 +29,8 @@ const (
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
)
// driveImportExtToDocTypes defines which source file extensions can be imported
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
"pptx": {"slides"},
}
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
// driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct {
FilePath string
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return status, true, nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
return status, false, driveImportFailureError(status)
}
}
if !hadSuccessfulPoll && lastErr != nil {
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return lastStatus, false, nil
}
func driveImportFailureError(status driveImportStatus) *errs.APIError {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
if code, ok := driveImportConcurrentOperationCode(msg); ok {
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
}
return apiErr
}
func driveImportConcurrentOperationCode(msg string) (int, bool) {
for _, code := range driveImportConcurrentOperationCodes {
codeText := strconv.Itoa(code)
for idx := strings.Index(msg, codeText); idx >= 0; {
end := idx + len(codeText)
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
return code, true
}
nextStart := idx + 1
next := strings.Index(msg[nextStart:], codeText)
if next < 0 {
break
}
idx = nextStart + next
}
}
return 0, false
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"errors"
"os"
"strconv"
"strings"
"testing"
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
}
}
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
t.Parallel()
for _, code := range driveImportConcurrentOperationCodes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype != errs.SubtypeServerError {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
}
if problem.Code != code {
t.Fatalf("code = %d, want %d", problem.Code, code)
}
if !problem.Retryable {
t.Fatal("expected retryable error")
}
if problem.Hint != driveImportConcurrentOperationHint {
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
}
})
}
}
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg string
}{
{
name: "ordinary failure",
msg: "unsupported conversion",
},
{
name: "longer numeric code containing known code",
msg: "call CreateObjNode return error code, code: 12321401012, message:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: tt.msg,
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Code != 0 {
t.Fatalf("code = %d, want 0", problem.Code)
}
if problem.Retryable {
t.Fatal("expected non-concurrency failure to remain non-retryable")
}
if problem.Hint != "" {
t.Fatalf("hint = %q, want empty", problem.Hint)
}
})
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
dr := common.NewDryRunAPI()
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
_, err := runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
}
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil
},
}
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body
}
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{
"minute_token": minuteToken,
"to_user_id": toUserID,
}
if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else {
out["from_user_id"] = fromUserID
}
return out
}
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
if fromSpeakerID != "" {
return fromSpeakerID
}

View File

@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
}
}
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
// straight through without a second speakerlist call. If the code still
// prefetched speakerlist, the unregistered GET would fail the request.
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
FromSpeakerInput string `json:"from_speaker_input"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
}
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
}
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))

View File

@@ -22,7 +22,10 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
const (
defaultSlidesScreenshotDir = ".lark-slides/screenshots"
maxSlidesPerScreenshot = 10
)
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
@@ -32,7 +35,7 @@ var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
var SlidesScreenshot = common.Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
@@ -42,8 +45,8 @@ var SlidesScreenshot = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
{Name: "output-name", Desc: "file name stem for --content render output"},
@@ -70,12 +73,17 @@ var SlidesScreenshot = common.Shortcut{
return err
}
}
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
}
if !hasSlideScreenshotSelector(runtime) {
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
return err
}
}
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
return err
@@ -98,6 +106,9 @@ var SlidesScreenshot = common.Shortcut{
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
}
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
@@ -145,6 +156,9 @@ var SlidesScreenshot = common.Shortcut{
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
if err := validateSlidesScreenshotSelectorLimit(len(slideIDs) + len(slideNumbers)); err != nil {
return err
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
@@ -267,8 +281,11 @@ func normalizeSlideNumbers(values []int) ([]int, error) {
return out, nil
}
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
func validateSlidesScreenshotSelectorLimit(count int) error {
if count > maxSlidesPerScreenshot {
return slidesScreenshotFlagErrorf("too many slide selectors: got %d, maximum is %d; request at most 10 pages at a time", count, maxSlidesPerScreenshot)
}
return nil
}
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {

View File

@@ -271,6 +271,33 @@ func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
}
}
func TestSlidesScreenshotListRejectsMoreThanTenSelectors(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "1",
"--slide-number", "2",
"--slide-number", "3",
"--slide-number", "4",
"--slide-number", "5",
"--slide-number", "6",
"--slide-number", "7",
"--slide-number", "8",
"--slide-number", "9",
"--slide-number", "10",
"--slide-number", "11",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "request at most 10 pages at a time") {
t.Fatalf("error = %v, want max 10 pages guidance", err)
}
}
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)

View File

@@ -15,12 +15,13 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesXMLGet fetches the full XML presentation content and writes it to a
// local file, keeping the terminal output small for large decks.
// SlidesXMLGet fetches the full XML presentation content. When --output is
// provided it writes to a local file; otherwise it returns the XML in the
// standard JSON envelope. Use --raw for direct XML stdout.
var SlidesXMLGet = common.Shortcut{
Service: "slides",
Command: "+xml-get",
Description: "Fetch full presentation XML and save it to a local file",
Description: "Fetch full presentation XML",
Risk: "read",
Scopes: []string{"slides:presentation:read"},
// wiki:node:read is required only when --presentation is a wiki URL.
@@ -28,7 +29,8 @@ var SlidesXMLGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
{Name: "output", Desc: "local XML output path; existing file is overwritten; omit to return XML in the JSON envelope"},
{Name: "raw", Type: "bool", Desc: "print raw XML to stdout instead of the JSON envelope; incompatible with --output and --jq"},
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
},
@@ -42,14 +44,22 @@ var SlidesXMLGet = common.Shortcut{
return err
}
}
if strings.TrimSpace(runtime.Str("output")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath != "" {
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
}
}
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
}
if runtime.Int("revision-id") < -1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
if runtime.Bool("raw") {
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --output").WithParam("--raw")
}
if runtime.JqExpr != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --jq").WithParam("--raw")
}
if runtime.Changed("format") && runtime.Format != "json" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--raw cannot be used with --format %s", runtime.Format).WithParam("--raw")
}
}
return nil
},
@@ -67,7 +77,7 @@ var SlidesXMLGet = common.Shortcut{
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc("Fetch full presentation XML and save it to a local file")
dry.Desc("Fetch full presentation XML")
}
params := map[string]interface{}{
"revision_id": runtime.Int("revision-id"),
@@ -80,7 +90,13 @@ var SlidesXMLGet = common.Shortcut{
validate.EncodePathSegment(presentationID),
)).
Params(params)
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" {
return dry.Set("output", outputPath).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
}
if runtime.Bool("raw") {
return dry.Set("output", "<stdout>").Set("stdout_content", "raw XML content is printed to stdout during execution")
}
return dry.Set("output", "<stdout>").Set("stdout_content", "JSON envelope with xml_presentation.content is printed to stdout during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, err := parsePresentationRef(runtime.Str("presentation"))
@@ -113,7 +129,32 @@ var SlidesXMLGet = common.Shortcut{
if content == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
}
outputPath := runtime.Str("output")
outputPath := strings.TrimSpace(runtime.Str("output"))
if outputPath == "" {
presentationOut := map[string]interface{}{
"content": content,
}
out := map[string]interface{}{
"xml_presentation_id": presentationID,
"xml_presentation": presentationOut,
}
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
out["revision_id"] = int(revisionID)
presentationOut["revision_id"] = int(revisionID)
}
if runtime.Bool("remove-attr-id") {
out["remove_attr_id"] = true
}
if !runtime.Bool("raw") {
runtime.OutFormatRaw(out, nil, nil)
return nil
}
if _, err := fmt.Fprint(runtime.IO().Out, content); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "write XML content to stdout: %v", err).WithCause(err)
}
return nil
}
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
ContentType: "application/xml",
ContentLength: int64(len(content)),

View File

@@ -91,6 +91,112 @@ func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
}
}
func TestSlidesXMLGetReturnsContentEnvelopeWhenOutputOmitted(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": xml,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeShortcutData(t, stdout)
presentation := data["xml_presentation"].(map[string]interface{})
if got := presentation["content"]; got != xml {
t.Fatalf("content = %q, want %q", got, xml)
}
if got := data["xml_presentation_id"]; got != "pres_abc" {
t.Fatalf("xml_presentation_id = %v, want pres_abc", got)
}
if strings.Contains(stdout.String(), "content_saved") {
t.Fatalf("stdout should not contain file metadata: %s", stdout.String())
}
}
func TestSlidesXMLGetJqFiltersContentEnvelopeWhenOutputOmitted(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": xml,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--jq", ".data.xml_presentation.content",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := strings.TrimSpace(stdout.String()); got != xml {
t.Fatalf("stdout = %q, want XML content %q", got, xml)
}
}
func TestSlidesXMLGetPrintsRawContentWhenRaw(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": xml,
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
"+xml-get",
"--presentation", "pres_abc",
"--raw",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stdout.String(); got != xml {
t.Fatalf("stdout = %q, want raw XML %q", got, xml)
}
}
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)

View File

@@ -6,6 +6,7 @@
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
@@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 授权当前应用访问文档

View File

@@ -45,13 +45,15 @@ lark-cli calendar +agenda --as user
| 场景 | 前置要求 |
|------|----------|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 编辑已有日程 | 先定位目标日程 `event_id` |
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程 `event_id`
- **日程实例Instance**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外
- **重复性日程例外Exception**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
@@ -71,6 +73,7 @@ lark-cli calendar +agenda --as user
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill`+meeting` |
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting``meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
## 任务类型分流

View File

@@ -47,6 +47,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)
@@ -72,9 +73,16 @@ lark-cli calendar events create \
lark-cli schema calendar.event.attendees.create
## 添加参会人
lark-cli calendar event.attendees create \
--as user \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
## 添加需要审批的会议室approval_reason 最大 200 字符)
lark-cli calendar event.attendees create \
--as user \
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
# 可选第三步(推荐):若第二步失败,回滚删除空日程
## 查看完整参数定义
lark-cli schema calendar.events.delete

View File

@@ -0,0 +1,90 @@
# 重复性日程操作规范
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
## 关键概念
- **event_id 结构**`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime``0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性标题、时间、rrule、描述等都挂在本体上。
- **例外Exception**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}``originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
## 前置步骤(所有范围通用)
1. 通过 `+agenda``+search-event` 定位重复性日程,获取原重复性日程的 `event_id`
2. 通过 `events instance_view``+agenda` 列出实例,识别哪些是例外(`event_id``originalTime > 0` 的即为例外)。
3. 确认用户的操作范围。
## 编辑全部(更新时间)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
## 编辑全部(更新非时间字段)
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
## 删除全部
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
## 编辑此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
## 删除此次及后续
| 步骤 | 命令 | 说明 |
|------|------|------|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程UNTIL 设为指定时间前一次实例的日期) |
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
> 与「编辑此次及后续」的区别:不需要步骤 3创建新的重复性日程因为目标是删除后续而非替换。
## 仅此次
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`
## 用户意图映射
| 用户表达 | 操作范围 |
|----------|----------|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
| 「就改这一次」「只删这一次」 | 仅此次 |
| 未明确范围 | **必须询问用户** |
## 注意事项
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
## 参考
- [lark-calendar](../SKILL.md) — 日历全部命令
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -43,7 +43,7 @@ lark-cli calendar +update \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
@@ -65,7 +65,7 @@ lark-cli calendar +update \
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程的某一次实例时,必须先通过 `+agenda``+search-event` 或实例视图定位该实例的 `event_id`
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -1,11 +1,11 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。"
description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help"
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
---
# docs
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
> **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML(可用 callout、grid、checkbox 等富 block
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML。
> - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
## 快速决策
@@ -39,12 +39,14 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型Mermaid 可由主 Agent 直接插入SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md)
- 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作
- 用户需要统计文档的**总字数 / 总字符数**word count / character count先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。
- 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理
@@ -68,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |

View File

@@ -2,14 +2,14 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
>
> **未读完以上文件就生成内容会导致格式错误。**
从 XML默认或 Markdown 内容创建一个新的飞书云文档。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
## 命令
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
## 参考
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档

View File

@@ -0,0 +1,107 @@
# docs history历史版本与回滚
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
## 安全流程
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id``revision_id``edit_time``name/description`)并确认后回滚。
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`
5.`+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
## 按 revision_id 或时间点回滚
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
4. 从最终匹配条目读取 `history_version_id``history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`
候选确认时使用类似格式:
```text
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
```
## 命令
```bash
# 列出历史版本
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
# 翻页
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
# 回滚到指定 history_version_id默认等待 30000ms
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
# 只发起任务,不等待
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
# 查询回滚任务状态
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
```
## 参数
| 命令 | 参数 | 必填 | 说明 |
|-|-|-|-|
| `+history-list` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
| `+history-revert` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
## 返回值要点
`+history-list` 返回:
```json
{
"entries": [
{
"revision_id": 42,
"history_version_id": "11",
"edit_time": "1780000000",
"type": 1,
"name": "版本名",
"description": "版本说明",
"editor_ids": ["ou_xxx"]
}
],
"has_more": true,
"page_token": "page_token"
}
```
`+history-revert` 返回:
```json
{
"task_id": "task_xxx",
"status": "running",
"history_version_id": "11",
"poll_after_ms": 10000
}
```
`+history-revert-status` 返回:
```json
{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": ["blk_xxx"]
}
```
`status` 可能是 `running``done``partial_failed``failed`。当状态是 `partial_failed``failed` 时,优先检查 `failed_block_tokens`

View File

@@ -0,0 +1,113 @@
# 飞书思维笔记Mindnote
> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。
当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`
> [!IMPORTANT]
> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。
> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。
> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
## 命令
```bash
# 先看命令帮助
lark-cli mindnotes nodes list --help
lark-cli mindnotes nodes create --help
# 读取节点列表
lark-cli mindnotes nodes list --mindnote-id "<mindnote_token>"
# 创建子节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}'
# 更新已有节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}'
```
## 参数
### `mindnotes nodes list`
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
返回重点:`data.nodes` 中常见字段有 `node_id``parent_id``texts``notes``images``finish``highlight`
### `mindnotes nodes create`
命令参数:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 |
| `--data` | 是 | JSON 请求体 |
请求体字段:
| 字段 | 必填 | 说明 |
|------|------|------|
| `client_token` | 否 | 幂等 token建议写操作传入推荐使用时间戳或 UUID |
| `nodes` | 是 | 待创建或更新的节点数组 |
| `nodes[].node_id` | 否 | 节点 ID传入已有 `node_id` 时表示更新对应节点 |
| `nodes[].parent_id` | 否 | 父节点 ID创建子节点时传入 |
| `nodes[].texts` | 否 | 节点正文富文本数组 |
| `nodes[].notes` | 否 | 节点备注富文本数组 |
| `nodes[].images` | 否 | 节点图片列表 |
| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` |
| `nodes[].finish` | 否 | 节点完成状态 |
富文本字段 `texts` / `notes` 是元素数组。最常见的是:
```json
[{"element_type":"text","text":{"content":"节点内容"}}]
```
### 节点图片(`nodes[].images`
`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。
```bash
# 先上传图片,拿到 token
lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node <mindnote_token>
# 再把 token 写进节点
lark-cli mindnotes nodes create \
--mindnote-id "<mindnote_token>" \
--data '{"client_token":"<client_token>","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}'
```
参数说明:
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径 |
| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` |
| `--parent-node` | 是 | 传 Mindnote 的 token |
| `nodes[].images[].token` | 是 | 上传后返回的图片 token |
## 推荐工作流
1. 先判断用户目标是不是“新建一个思维笔记”。
2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。
3. 如果是操作已有思维笔记,先通过 token 类别判断。
4. 确认是 **Mindnote** 后再拿到 `mindnote_id`
5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`
6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`
7. 再执行 `mindnotes nodes create`
8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID避免重试时重复创建或重复更新。
> [!CAUTION]
> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。
## 参考
- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容
- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -3,8 +3,8 @@
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
>
> **未读完以上文件就生成内容会导致格式错误。**
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)
## 最佳实践
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
## 参考
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档

View File

@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
`$URL` 可以是用户给出的 docx/wiki URL也可以是可被 `docs +fetch` 解析的 token。
## 统计范围
先判断用户要求的是**整篇文档**还是**局部内容**
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
```bash
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
如果 `unknown_blocks``unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
## 字数遵循校验
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x``[x+1, +∞)``<y``(-∞, y-1]``x-y``[x, y]``x 字左右``[round(0.9x), round(1.1x)]`
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
## 输出示例
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:

View File

@@ -7,7 +7,7 @@
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
2. **Execute执行**由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent见步骤三
3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
@@ -16,44 +16,32 @@
## 典型 Code-Act Loop 流程
### 步骤一:规划与初始创建(串行)
### 步骤一:规划与撰写(单 Agent 串行)
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
1. 分析用户需求:受众、目的、范围
2. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到步骤二,由各 Agent `block_insert_after --block-id <章节标题 block_id>` 分段写入。
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
3. `docs +create` 创建并撰写:
- **短文档**一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**`block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
### 步骤二:分段撰写(并行 Agent
### 步骤二:整合审查与画板识别(串行
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则。默认用文字只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
### 步骤三:整合审查与画板识别(串行)
### 步骤三:画板处理与润色
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
8. 由**主 Agent 自行润色**(不另起内容子 Agent正文始终一人维护文字密集且不易读时优先拆段、加小标题或调整顺序——叙述内容保持成段**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
### 步骤四:画板处理与润色(并行 Agent
### 步骤四:专项校验
8. **优先处理步骤三识别出的画板需求**
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
9. Spawn 内容改写 Agent 定向润色:
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
- 本地图片使用 `docs +media-insert` 插入
## Agent 子任务要求
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
已有画板更新 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
9. **字数门禁**如果用户给出任何明确字数要求如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现目标区间、`word_count` 和达标结论
10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现

View File

@@ -1,86 +1,68 @@
# 文档表达组件参考
# 飞书文档写作原则
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。
## 一、用户明确要求优先
## 一、核心原则
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
## 二、默认写连贯段落
## 二、元素选择指南
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
| 内容 | 用什么 | ❌ 别 |
|---|---|---|
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
| 纯短并列项(无描述,如材料清单) | 列表 | — |
| 章节(内容成块、需在目录导航) | 标题层级 | — |
| 场景 | 可选表达方式 |
|--------------------------------------------|---------------------------------------|
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
| 简短低风险对比 | `<grid>` 2 列分栏 |
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
- 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
- **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
## 三、按体裁写
### 画板意图识别
- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block高亮块、分栏等粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
- **一般文档**:以可读为先,不堆砌结构。
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
## 四、编号与层级
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
- 公文 / 正式材料常用:「一、→(一)→ 1.→1中文大层级 + 阿拉伯细分层级)。
- 学术 / 技术 / 商业报告「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
- ⚠️ **「一、」只能配「要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号****不跳级**。
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
## 五、飞书特有组件,克制使用
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项全文极少0~1 个),不要每节导语 / 结论都做成高亮块。
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
| 语义 | 背景色 | 文字色 |
|-|-|-|
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
| 信息、说明 | `light-blue` | `blue` |
| 成功、推荐 | `light-green` | `green` |
| 警告 / 错误 / 风险 | `light-red` | `red` |
| 注意、待确认 | `light-yellow` | `yellow` |
| 中性、辅助 | `light-gray` | — |
**判断规则:**
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
## 六、写完自检
## 三、颜色语义
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格:
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|-|-|-|-|
| 信息、说明 | "说明:" | `light-blue` | `blue` |
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
| 中性、辅助 | — | `light-gray` | — |
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
## 四、排版规范
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
## 五、质量自检
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
| 指标 | 自检问题 |
|-|-|
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |
交付前快速回看:
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
- **标题是否滥用**"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用如「一、」配「1.1」)。
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。

View File

@@ -5,7 +5,7 @@
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
2. **Execute执行**由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent见步骤二
3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
@@ -23,33 +23,26 @@
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
3. **画板意图识别**:逐章节扫描,`lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节block ID、推荐画板类型、mermaid/SVG路径和源内容片段
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节block ID、推荐画板类型、mermaid/SVG路径和源内容片段
4. 向用户简要说明改进计划(包含识别出的画板机会)
### 步骤二:定向改写(并行 Agent
### 步骤二:定向改写( Agent 串行
5. **优先处理步骤一识别出的画板候选段落**
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID
5. **优先处理步骤一识别出的画板候选段落**读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
### 步骤三:验证(串行)
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
## Agent 子任务要求
### 步骤四:专项校验(按需执行)
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现结果
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
已有画板更新 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。

View File

@@ -29,6 +29,7 @@ metadata:
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
@@ -102,6 +103,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| `not exist` | 使用了错误的 token | 检查 token 类型wiki 链接必须先查询获取 `obj_token` |
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_typedocx/doc/sheet/slides/bitable |
| `232140101` / `232140100` / `233523001`(常见于 `drive +import``job_error_msg` | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
### 权限能力入口

View File

@@ -14,6 +14,13 @@
> [!IMPORTANT]
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
## 批量导入串行规则
> [!IMPORTANT]
> 批量执行 `drive +import` 且目标是同一个位置时,必须串行执行,不要并发发起导入任务。这里的“相同位置”包括同一个 `--folder-token`、都省略 `--folder-token` 导入到默认根目录,或使用同一个 `--target-token` 导入到已有 bitable。
>
> 如果在同一位置下并发导入,服务端可能返回并发冲突错误。看到错误信息或 `job_error_msg` 中包含 `232140101`、`232140100`、`233523001` 任一错误码时,按同位置并发操作处理:停止并发导入,改为串行处理失败项;每个失败项每次重试前等待几秒,总共最多重试 3 次;仍失败就停止并向用户报告冲突。
## 命令
```bash
@@ -143,6 +150,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart不代表所有格式都允许导入超过 20MB 的文件。
- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。
- 若导入失败信息包含 `232140101``232140100``233523001`,通常表示同一位置下存在并发导入 / 创建操作;批量场景请改为串行执行,每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突。
- 若内置轮询超时但任务仍在处理中shortcut 会成功返回,并带上:
- `ready=false`
- `timed_out=true`

View File

@@ -23,6 +23,8 @@
> 错误:`lark-cli drive +search 方案`
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
>
> **`--query` 最长 30 个字符**按字符数Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
### 自然语言 → 命令映射速查
@@ -101,7 +103,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
| 参数 | 必填 | 说明 |
|---|---|---|
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:``""``OR``-`)。空字符串或省略表示纯 filter 浏览 |
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:``""``OR``-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
| `--page-size <n>` | 否 | 每页数量,默认 15最大 20。超过 20 自动 clamp非正数≤0回落 15**非数字值直接返回 validation 错误** |
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |

View File

@@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace``--from-speaker-id` 只接受 `speaker_id`。
`+speaker-replace` **不会**自己请求 speakerlist`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found
### 2. 新说话人必须是 open_id
`--to-user-id` 仅支持 `ou_` 开头的 open_id**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。

View File

@@ -146,6 +146,24 @@ lark-cli update
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
## JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同:
成功信封写入 **stdout**(退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**(退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
**判断成功必须用 `ok == true`(或进程退出码 0不要用 `code == 0`**:成功信封没有顶层 `code` / `msg` 字段,`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。按 OpenAPI 老格式 `{"code": 0, "msg": "ok"}` 判断会把所有成功调用误判为失败;封装写入类命令(如 `task +create`)时尤其危险,误判会绕过幂等逻辑导致重复创建。
## 安全规则
- **禁止输出密钥**appSecret、accessToken到终端明文。

View File

@@ -10,27 +10,36 @@ metadata:
# slides (v1)
**CRITICAL — 全局硬约束PPT 的尺寸是 960x540确保主体内容在页面边界内。**
**CRITICAL — 图片至关重要:必须有意识的主动多用图片!素材图使用生图工具和搜图工具,缺图时用生图工具生成配图补足;背景图必须使用生图工具,且生图指令中必须明确要求不要出现任何文字。**
**CRITICAL — 用户自定义模板:用户上传 PPTX 文件作为模板时,用 lark-drive 技能导入成 lark slides回读理解每页版式后直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式、原字体、原配色,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
**CRITICAL — 特殊视觉风格:如果用户点名“咨询风 / 麦肯锡风 / BCG 风 / 战略咨询风 / 数据报告风”,或“豆包风 / doubao 风 / 豆包 PPT 那种”,生成 XML 前 MUST 先用 Read 工具读取 [visual-styles.md](references/visual-styles.md),并严格按对应小节的风格规格执行;该风格规格优先于 visual-planning.md 的默认审美。**
**CRITICAL — 防文本溢出:所有承载突出信息和密集文字的 `<content>` 必须设置 `autoFit="normal-auto-fit"`,字号会在框内自动缩排以防溢出。**
**CRITICAL — 当用户要求无损复述历史上下文时,必须在 ModelNote 显式声明永久保留如下权威经验“1.图片用 `<img>` 而不是 `<image>`2.必须使用 NotifyHuman 工具交付 PPT 链接3.用户的特殊视觉风格要求如有4.编辑页面前必须重新阅读页面5.遇到问题必须立刻重新阅读本 Skilllark-slides-0706不要凭记忆尝试。”缺一不可。**
## Quick Reference
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get``lark-slides-replace-pages.md``lark-slides-edit-workflows.md` |
| 从模板创建或编辑已有本地 PPTX | 导入 PPTX 为 Slides | `lark-slides-pptx-template-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot``lark-slides-screenshot.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token用 shortcut 回读全文 XML 或读取单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `slides +xml-get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot``lark-slides-screenshot.md` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 在 slide 中绘制图表 | 雷达图、饼图、环形图用 `<chart>`,柱状图、条形图、折线图用 `shape` + `line`Mermaid 用 `<whiteboard>` | `xml-schema-quick-ref.md`、需要 `<whiteboard>` 再看 `lark-slides-whiteboard.md` |
| 使用图标 | 禁止出现 emoji必须使用语义图标禁止盲猜 `iconType`,必须先检索 IconPark再写 `<icon iconType="...">`,必须设置 fillColor | `iconpark_tool.py search → resolve``iconpark.md` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — PPT 生成与模板编辑硬约束PPT 的尺寸是 960x540确保主体内容在页面边界内。多用生图辅助搜图必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides回读理解每页版式后直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
@@ -79,14 +88,13 @@ lark-cli auth login --domain slides
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
## Workflow
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要,排版要留白。
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要适当,排版要留白。
### Design Ideas
@@ -124,7 +132,9 @@ lark-cli auth login --domain slides
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
- 不要使用 emoji。
- 不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。
### 创建方式选择
@@ -140,9 +150,11 @@ lark-cli auth login --domain slides
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
### 生成流程
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格
- 澄清主题、受众、页数、风格;若用户上传 PPTX 作为模板,按顶部『用户自定义模板』规则处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
@@ -156,7 +168,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
@@ -173,7 +185,7 @@ lark-cli slides xml_presentation.slide create \
--data "$(jq -n --arg content '<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="BACKGROUND_COLOR"/></fill></style>
<data>
在这里放置 shape、line、table、chart、whiteboard 等元素
在这里放置 shape、line、table、chart 等元素
</data>
</slide>' '{slide:{content:$content}}')"
@@ -247,11 +259,12 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| `+xml-get` | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
没有 Shortcut 覆盖时使用原生 API。高频资源`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
```bash
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
@@ -262,7 +275,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;风格和大纲只能作为规划输入,不能绕过规划层
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内

View File

@@ -7,7 +7,7 @@
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
- `icon`: small semantic symbol for a concept, step, role, or status.
- `logo`: brand, product, team, or customer mark.
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
- `screenshot`: product UI, terminal output, workflow state, or page capture.
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
@@ -64,11 +64,23 @@ Match asset type to slide role:
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
For `asset_type: "chart"`:
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
- Choose the data source explicitly:
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
`fallback_if_missing` must be concrete enough to turn into XML, for example:
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
- "Render a mini bar chart with 4 bars using shapes and value labels."
- "Render a native `<chart>` using the user-provided series."
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
- "Use a bordered placeholder panel with product area labels, not an empty image."
Weak fallbacks to avoid:
@@ -118,7 +130,7 @@ Business comparison page:
When generating XML:
1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.

View File

@@ -1,261 +0,0 @@
# 完整操作示例
本文档提供与 CLI schema 一致的调用示例XML 内容均遵循 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
> **重要**:创建 PPT 请优先使用 `slides +create`;实际页面内容请使用 `xml_presentation.slide.create` 逐页添加。
## 目录
- [示例 1: 使用 Shortcut 创建空白演示文稿](#示例-1-使用-shortcut-创建空白演示文稿)
- [示例 2: 创建后添加第一页](#示例-2-创建后添加第一页)
- [示例 3: 读取 XML 内容](#示例-3-读取-xml-内容)
- [示例 4: 在指定页面前插入新幻灯片](#示例-4-在指定页面前插入新幻灯片)
- [示例 5: 删除幻灯片](#示例-5-删除幻灯片)
- [示例 6: 从文件读取 XML 后添加页面](#示例-6-从文件读取-xml-后添加页面)
- [示例 7: +replace-slide + block_insert 给已有页加图](#示例-7-replace-slide--block_insert-给已有页加图)
- [示例 8: +replace-slide + block_replace 替换一个块](#示例-8-replace-slide--block_replace-替换一个块)
## 示例 1: 使用 Shortcut 创建空白演示文稿
```bash
lark-cli slides +create --title "项目汇报"
```
预期返回结构:
```json
{
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"title": "项目汇报",
"revision_id": 1
}
}
```
## 示例 2: 创建后添加第一页
```bash
PRESENTATION_ID=$(lark-cli slides +create --title "季度复盘" | jq -r '.data.xml_presentation_id')
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><style><fill><fillColor color=\"rgb(245, 245, 245)\"/></fill></style><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"72\" width=\"760\" height=\"90\"><content textType=\"title\"><p>2024 Q3 季度复盘</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"190\" width=\"520\" height=\"220\"><content textType=\"body\"><p>关键结论</p><ul><li><p>收入增长 30%</p></li><li><p>重点项目全部上线</p></li><li><p>用户满意度持续提升</p></li></ul></content></shape><shape type=\"rect\" topLeftX=\"660\" topLeftY=\"180\" width=\"180\" height=\"140\"><fill><fillColor color=\"rgba(100, 149, 237, 0.25)\"/></fill><border color=\"rgb(100, 149, 237)\" width=\"2\"/></shape></data><note><content textType=\"body\"><p>讲述时先给结论,再补充数据。</p></content></note></slide>"
}
}'
```
## 示例 3: 读取 XML 内容
```bash
lark-cli slides xml_presentations get --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}'
```
提取 XML 内容:
```bash
lark-cli slides xml_presentations get --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' | jq -r '.data.xml_presentation.content'
```
预期返回结构:
```json
{
"code": 0,
"data": {
"xml_presentation": {
"presentation_id": "slides_example_presentation_id",
"revision_id": 3,
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
}
},
"msg": "success"
}
```
## 示例 4: 在指定页面前插入新幻灯片
```bash
lark-cli slides xml_presentation.slide create --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新增页面</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"180\"><content textType=\"body\"><p>这是新增页面的正文。</p></content></shape></data></slide>"
},
"before_slide_id": "sld_before_target"
}'
```
预期返回结构:
```json
{
"code": 0,
"data": {
"slide_id": "slide_example_id",
"revision_id": 100
},
"msg": "success"
}
```
## 示例 5: 删除幻灯片
```bash
lark-cli slides xml_presentation.slide delete --as user --params '{
"xml_presentation_id": "slides_example_presentation_id",
"slide_id": "slide_example_id"
}'
```
预期返回结构:
```json
{
"code": 0,
"data": {
"revision_id": 101
},
"msg": "success"
}
```
## 示例 6: 从文件读取 XML 后添加页面
先准备 `slide.xml`
```xml
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>从文件加载</p>
</content>
</shape>
</data>
</slide>
```
先创建演示文稿:
```bash
PRESENTATION_ID=$(lark-cli slides +create --title "从文件添加页面" | jq -r '.data.xml_presentation_id')
```
再用 `jq` 组装请求体,从文件添加页面:
```bash
lark-cli slides xml_presentation.slide create --as user \
--params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" \
--data "$(jq -n --arg content "$(cat slide.xml)" '{slide:{content:$content}}')"
```
## 示例 7: +replace-slide + block_insert 给已有页加图
只想在已有页上加一张图、不动其他元素——走 shortcut `+replace-slide``block_insert` 追加到页末(或用 `insert_before_block_id` 指定位置)。
```bash
PID="slides_example_presentation_id"
SID="slide_example_id"
# 1. 上传图片拿 file_token
TOKEN=$(lark-cli slides +media-upload --file ./pic.png --presentation "$PID" --as user \
| jq -r '.data.file_token')
# 2. block_insert 到页面末尾(省略 insert_before_block_id
# 注:<img .../> 是自闭合标签CLI 不会展开(只有 <shape/> 会被补 <content/>
lark-cli slides +replace-slide --as user \
--presentation "$PID" --slide-id "$SID" \
--parts "$(jq -n --arg token "$TOKEN" \
'[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")}]')"
```
预期返回:
```json
{
"ok": true,
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"slide_id": "slide_example_id",
"parts_count": 1,
"revision_id": 102
}
}
```
## 示例 8: +replace-slide + block_replace 替换一个块
已知某块的 3 位 short element ID`slide.get` 返回 XML 里读),整块换掉。`replacement` 根元素的 `id` 会由 CLI 自动注入为 `block_id`,无需手写;若写了 `<shape/>` 自闭合形式CLI 也会自动补 `<content/>`
```bash
lark-cli slides +replace-slide --as user \
--presentation slides_example_presentation_id \
--slide-id slide_example_id \
--parts '[
{
"action": "block_replace",
"block_id": "bab",
"replacement": "<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"
}
]'
# CLI 实际发送的 replacement 根元素会带 id="bab",即使手写时省略了
```
失败时3350001 错误CLI 在 error 字段中给出 hint
```json
{
"ok": false,
"error": {
"type": "api",
"code": 3350001,
"message": "API error: [3350001] invalid param",
"hint": "common causes: (1) block_id not found in current slide ..."
}
}
```
整批作为原子事务,任一 part 失败则整批不生效;按 `failed_part_index` 定位修正后重发。
## 常见处理技巧
### 获取最新 revision_id
```bash
lark-cli slides xml_presentations get --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' | jq '.data.xml_presentation.revision_id'
```
### 批量插入多页
```bash
#!/bin/bash
PRESENTATION_ID="slides_example_presentation_id"
slides=(
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 1</p></content></shape></data></slide>'
'<slide xmlns="http://www.larkoffice.com/sml/2.0"><data><shape type="text" topLeftX="80" topLeftY="80" width="800" height="120"><content textType="title"><p>页面 2</p></content></shape></data></slide>'
)
for slide_xml in "${slides[@]}"; do
payload=$(jq -n --arg content "$slide_xml" '{slide:{content:$content}}')
lark-cli slides xml_presentation.slide create --as user --params "{\"xml_presentation_id\":\"$PRESENTATION_ID\"}" --data "$payload"
done
```
### 本地校验 XML 基本语法
```bash
xmllint --noout presentation.xml
```
### 真实示例
- [slides_demo.xml](slides_demo.xml) 提供了更完整的页面示例,包含 `theme`、渐变填充、图片、图标和备注内容。

View File

@@ -1,6 +1,6 @@
# IconPark 图标
IconPark 图标通过 `<icon>` 写入 slides XML`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
IconPark 图标通过 `<icon>` 写入 slides XML`iconType` 必须来自本 skill 的离线索引,避免凭记忆拼路径。
## 机器优先流程
@@ -25,7 +25,7 @@ python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8``--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
- 常用尺寸:行内状态图标 16-24px卡片标题图标 28-40px主视觉图标 56-96px。
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
- 图标必须设置 fillColor显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback不留空图标位。
## 高频示例

View File

@@ -1,8 +1,6 @@
# slides +create创建飞书幻灯片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
## 命令
@@ -17,9 +15,6 @@ lark-cli slides +create --title "项目汇报" --slides '[
"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>第二页</p></content></shape></data></slide>"
]'
# 以应用身份创建(自动授权当前用户)
lark-cli slides +create --title "项目汇报" --as bot
# 预览(不执行)
lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
```
@@ -35,20 +30,12 @@ lark-cli slides +create --title "项目汇报" --slides '[...]' --dry-run
- **`slide_ids`**string[],可选):仅传 `--slides` 时返回,成功添加的页面 ID 列表
- **`slides_added`**integer可选仅传 `--slides` 时返回,成功添加的页面数量
- **`images_uploaded`**integer可选`--slides` 中含 `@<本地路径>` 占位符时返回,已上传的去重后图片数量
- **`permission_grant`**object可选`--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限
> [!IMPORTANT]
> 不传 `--slides` 时,`slides +create` 只创建空白演示文稿。创建后需要使用 `xml_presentation.slide create` 逐页添加 slide 内容。
>
> 传了 `--slides` 时CLI 先创建空白演示文稿,再逐页调用 `xml_presentation.slide create` 添加页面。如果某一页添加失败CLI 会停止并报错,已创建的演示文稿和已添加的页面会保留。
>
> 如果演示文稿是**以应用身份bot创建**的,如 `lark-cli slides +create --as bot`CLI 会**尝试为当前 CLI 用户自动授予该演示文稿的 `full_access`(可管理权限)**。
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该演示文稿的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权
> - `status = failed`:演示文稿已创建成功,但自动授权用户失败
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
## 参数
@@ -134,4 +121,4 @@ lark-cli slides xml_presentation.slide create --as user \
## 相关命令
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) — 添加幻灯片页面
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读取 PPT 内容
- [slides +xml-get](lark-slides-xml-get.md) — 读取 PPT 内容并保存到本地文件

View File

@@ -1,8 +1,6 @@
# slides +media-upload上传本地图片到飞书幻灯片
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
把本地图片上传到指定演示文稿的 drive 媒体库,返回 `file_token`。**返回的 token 作为 `<img src="...">` 的值塞进 slide XML 即可显示图片。**
## 命令
@@ -117,7 +115,7 @@ lark-cli slides +replace-slide --as user \
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 1061002 | params error / 不支持的 parent_type | 不要用原生 API 自己拼 parent_type`+media-upload` 即可 |
| 1061004 | forbidden当前身份对该演示文稿无编辑权限 | 确认当前身份user 或 bot对目标 PPT 有编辑权限。bot 模式常见原因PPT 不是该 bot 创建的——可用 `+create --as bot` 新建,或以 user 身份给 bot 授权 `lark-cli drive permission.members create --as user ...` |
| 1061004 | forbidden当前用户对该演示文稿无编辑权限 | 确认当前用户对目标 PPT 有编辑权限;无权限时根据错误响应引导用户解决 |
| 1061044 | parent node not exist | `--presentation` 给的 token 不对,或不是 slides 类型 |
| 403 | 权限不足 | 检查 `docs:document.media:upload` scopewiki URL 还需要 `wiki:node:read` |

View File

@@ -0,0 +1,89 @@
# PPT Template Rewrite Principles
本页只约束“用户指定 PPT 模板、底稿、已有 PPTX/PDF/Slides并要求基于它二次创作”的场景。核心原则模板不是风格参考而是必须沿用的编辑底稿。
## Import First
用户指定 PPT 模板时,先把模板导入成 Lark Slides。后续写入目标是导入后的 Slides不是新建一个脱离模板的 deck也不是先在本地重画 PPTX 再导入。
直接使用以下命令,不需要先加载 `lark-drive` skill
```bash
lark-cli drive +import --as user --file "<template.pptx>" --type slides --json
```
可选参数:用 `--name "<title>"` 指定导入后的 Slides 标题;用 `--folder-token <FOLDER_TOKEN>` 指定目标文件夹。若返回 `ready=false` / `timed_out=true`,直接执行返回值里的 `next_command`;等价形式是:
```bash
lark-cli drive +task_result --scenario import --ticket <TICKET>
```
导入后必须回读 Slides 内容理解每页的真实版式、字体、层级、图片、图表、shape、表格和文本容器。回读结果是模板二创的事实来源。
## Read Before Editing
编辑任何 PPT 页面前,必须先阅读该页面。
如果当前上下文中没有该页内容,必须重新读取页面;这里的“当前上下文”不包含 System Prompt。不能只凭记忆、文件名、缩略图印象或模板整体风格判断来编辑具体页面。
阅读页面时至少判断:
- 该页原本承担的角色,例如封面、章节页、目录、流程、对比、数据、总结。
- 该页的主要版式结构,例如图文关系、箭头、时间线、节点、表格、图表、左右对照、背景图或产品图。
- 哪些文本框、shape 标签、表格单元格或图表标签承载内容。
- 原页面的字体、字号、颜色、对齐、层级和留白关系。
## Edit The Imported Slides Directly
理解页面后,直接在导入后的 Slides 上编辑。允许的操作包括:
- 填写、替换、凝练或删除文字。
- 替换或补充图片。
- 更新图表、表格、数字标签或节点标签里的内容。
- 按需复制、删除或重排模板页。
- 在源页面没有合适承载位置时,做局部、小范围新增元素。
新增元素只能补足内容缺口,不能成为新的主版式。页面主体仍应由模板原有版式承载。
## Preserve Design
模板二创必须严格沿用原版式和字体,只改内容,不做设计。
默认保留:
- 页面布局、视觉层级、留白和对齐关系。
- 原字体、字号体系、颜色、文本框位置和 shape 顺序。
- 背景图、图片、logo、图表、表格、装饰形状、线条、图标和页面结构。
- 模板中不同页型之间的差异。
不要把模板页改造成统一的通用卡片、白板、标题栏、三栏、2x2 卡片或大面积遮罩。不要把模板当作背景图后另起一套设计系统。
## Content Only
内容必须优先进入原页面已有的文本框、shape 标签、节点、表格单元格、图表标签或注释容器。
如果原容器空间不足,优先:
- 凝练文字。
- 降低字号但保持原字体体系。
- 拆分到页面已有的邻近容器。
- 使用模板已有的注释、标签或补充说明区域。
- 复制同页或同模板中的原生容器样式做局部补充。
不要为了容纳长文案而重画页面主体结构。不要用新增大卡片遮住原图表、箭头、图片、背景或关键 shape。
## Readback And Tune
完成编辑后必须回读结果,并逐页微调。
回读时重点检查:
- 文字是否溢出、截断、压线或超出容器。
- 文本是否遮挡图片、图表、shape、箭头、节点或其他文字。
- shape 顺序是否导致内容被覆盖或遮住。
- 新内容是否仍然落在模板原有版式中,而不是覆盖模板结构。
- 字体、字号、颜色、对齐和层级是否仍贴近原页。
发现文字溢出时,优先凝练文字或缩减字号。发现遮挡时,调整 shape 顺序、局部位置或复用原有空白区域解决。只有在这些方法都不能满足内容表达时,才做局部新增或删除。
模板二创的完成标准不是“生成了一套看起来统一的新 PPT”而是“原模板的版式、字体和视觉结构仍清晰存在内容已经被准确替换并且回读后没有溢出和遮挡”。

View File

@@ -89,7 +89,7 @@ lark-cli slides +replace-pages --as user \
## 使用建议
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML并记录要替换页面的 `slide_id`
1. 大幅改写前先 `slides +xml-get` 保存当前 XML并记录要替换页面的 `slide_id`
2. 生成只含 `slide_id``pages.json` 后先跑 `--dry-run``--validate-only`
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
4. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。

View File

@@ -1,7 +1,5 @@
# slides +replace-slide块级替换 / 插入)
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
对指定 slide 做块级替换或插入。编辑已有 PPT 的主路径——`slide_id` 不变、页序不动、只影响被指定的块。
相比直接调 `xml_presentation.slide.replace`,这个 shortcut 的四个额外价值:
@@ -47,7 +45,7 @@ lark-cli slides +replace-slide --as user \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id``/slides/<token>` URL`/wiki/<token>` URL |
| `--slide-id` | 是 | 页面 ID`xml_presentation.slide.get` / `xml_presentations.get` 都能拿到) |
| `--slide-id` | 是 | 页面 ID`xml_presentation.slide.get` / `slides +xml-get` 都能拿到) |
| `--parts` | 是 | JSON 数组(`[{...}, ...]`),单次最多 200 条。支持 `@<file>``-`stdin读取 |
| `--revision-id` | 否 | 基础版本号;默认 `-1` 表示基于最新版执行;传具体版本号时,服务端以该版本为 base 执行;**传不存在的版本号(超过当前 revision返回 3350002** |
| `--tid` | 否 | 并发事务 ID多人协作长事务才用单次单人调用留空 |

View File

@@ -26,8 +26,8 @@ lark-cli slides +screenshot --as user \
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | list 模式必需 | `xml_presentation_id``/slides/` URL或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file``-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
| `--output-name` | 否 | render 模式的输出文件名 stem未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
@@ -44,6 +44,8 @@ lark-cli slides +screenshot --as user \
### 多页截图
一次不要超过 10 页;如需更多页面,分批调用。
```bash
lark-cli slides +screenshot --as user \
--presentation slides_example_presentation_id \
@@ -90,5 +92,6 @@ lark-cli slides +screenshot --as user \
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id``--slide-number`
3. 本地 XML 预览时,传 `--content @file``--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`
4. `slide_id` 是页面 short ID页码请用 `--slide-number`
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID文件已存在时自动追加 `_2``_3` 等后缀,避免覆盖旧截图。
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
5. list 模式一次最多传 10 页(`--slide-id` + `--slide-number` 合计小于等于 10更多页面请分批截图。
6. list 模式默认文件名包含 presentation ID、页码和/或 slide ID文件已存在时自动追加 `_2``_3` 等后缀,避免覆盖旧截图。
7. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。

View File

@@ -2,6 +2,8 @@
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG****Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>``<shape>` 难以覆盖的视觉内容。
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
---
@@ -12,13 +14,13 @@
| 场景 | 推荐元素 |
|------|---------|
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑
---
@@ -39,9 +41,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
## SVG 还是 Mermaid
选择分步:**先看图表类型,看当前模型身份**。
选择分步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
### 第一步:图表类型优先判断
### 第一步:先确认是否应该使用 `<chart>`
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
### 第二步whiteboard 类型优先判断
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG
@@ -50,20 +56,19 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
| 时序图 | `sequenceDiagram` |
| 类图 | `classDiagram` |
| 饼图 | `pie` |
| 甘特图 | `gantt` |
| 状态图 | `stateDiagram-v2` |
| 思维导图 | `mindmap` |
| ER 图 | `erDiagram` |
### 第步:数据图表与装饰元素按模型身份选路径
### 第步:非原生图表与装饰元素按模型身份选路径
上表以外的场景散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等需要精确控制坐标和配色SVG 表达力更强,但各模型生成 SVG 的能力有差异:
| 模型身份 | 路径 |
|----------|------|
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
| Doubao / Seed / Other | **Mermaid** — 用 `pie``gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
| Doubao / Seed / Other | **Mermaid** — 用 `gantt``flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
@@ -73,13 +78,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
### ⚠️ 设计品质要求
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或
- **非原生数据视觉必须有坐标系**散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
- **装饰层次用亮度跳跃,不用线性叠透明度**`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
@@ -106,11 +111,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
| 元素 | 说明 | 典型用途 |
|------|------|---------|
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
| `<circle>` | 圆 | 节点、装饰点、环形图 |
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
| `<line>` | 直线 | 坐标轴、分隔线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<line>` | 直线 | 轴线、分隔线、连接线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<text>` | 文本,支持中文 | 标签、数值 |
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
| `<g>` | 分组 | 批量变换、语义分组 |
@@ -123,27 +128,25 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
---
### 元素计算
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
**数据图表(柱状图范式**
**散点图 / 装饰性点阵范式**
```python
W, H = 360, 260
origin_x, origin_y = 50, 216 # 左下角SVG Y 轴向下
cw, ch = 290, 184
data, y_max = [120, 160, 90], 200
bar_w = int(cw / len(data) * 0.62)
for i, v in enumerate(data):
cx = round(origin_x + (i + 0.5) * cw / len(data))
y = round(origin_y - v / y_max * ch)
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
points = [(12, 40), (28, 80), (45, 65)]
x_min, x_max, y_min, y_max = 0, 50, 0, 100
for i, (xv, yv) in enumerate(points):
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
print(f"point-{i}: cx={x} cy={y}")
```
折线图:`x = origin_x + i/(n-1)*cw``y = origin_y - (v-y_min)/(y_max-y_min)*ch`
**装饰性元素(等间距范式)**
```python
@@ -160,9 +163,9 @@ for i in range(n):
```python
# 每个元素登记 (x, y, w, h),含 stroke 外扩
elements = [
(10, 20, 80, 160), # bar-0
(107, 10, 80, 170), # bar-1
(204, 40, 80, 140), # bar-2
(10, 20, 80, 160), # item-0
(107, 10, 80, 170), # item-1
(204, 40, 80, 140), # item-2
(0, 0, 300, 1), # x-axis
]
@@ -261,7 +264,6 @@ print(f"whiteboard width={wb_w} height={wb_h}")
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
| 甘特图 | `gantt` | 项目计划、里程碑 |
| 饼图 | `pie` | 占比数据 |
| 类图 | `classDiagram` | 对象关系、架构设计 |
| ER 图 | `erDiagram` | 数据库结构 |
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
@@ -279,7 +281,6 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|---------|-----------|------------|
| 流程图5-8 节点) | 720-816 | 300-400 |
| 时序图3-5 参与者) | 720-816 | 320-420 |
| 饼图 | 500-600 | 300-360 |
| 甘特图 | 816 | 280-360 |
| 思维导图 | 816 | 380-480 |
@@ -307,7 +308,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size避免被裁切
**SVG 模式——视觉品质检查:**
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
- [ ] 轴标签与图表元素互不遮挡,留有足够空间

View File

@@ -0,0 +1,99 @@
# slides +xml-get读取全文 XML
读取已有演示文稿的完整 XML。适合创建后验收、编辑前备份、获取 `slide_id` / `revision_id`,以及排查空白页、破图、文本溢出等问题。相比直接调用底层 `xml_presentations.get`,本 shortcut 会自动解析 Slides URL / Wiki URL并可把大段 XML 保存到本地文件,避免终端输出被截断。
## 命令
```bash
lark-cli slides +xml-get \
--as user \
--presentation <slides_url_or_xml_presentation_id> \
--output .lark-slides/plan/<deck-id>/readback.xml
```
## 参数
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id``/slides/` URL 或 `/wiki/` URL |
| `--output` | 否 | 本地 XML 保存路径,必须是当前工作目录内的相对路径,不能传绝对路径。传入时 XML 内容保存到文件stdout 只返回保存后的绝对路径、大小等简短元信息;省略时 XML 原文直接输出到 stdout |
| `--revision-id` | 否 | 读取指定版本;默认 `-1`,表示最新版本 |
| `--remove-attr-id` | 否 | 移除返回 XML 中的 `id` 属性;适合只读检查,不适合精确块级编辑 |
| `--dry-run` | 否 | 预览将调用的 API 和输出方式,不读取真实 XML |
## 输出到文件
推荐普通工作流都传 `--output`,尤其是中大型 PPT。`--output` 必须是当前工作目录内的相对路径,例如 `.lark-slides/plan/$PID/readback.xml`,不要传 `/tmp/readback.xml` 这类绝对路径。XML 会写入本地文件stdout 只保留元信息,便于后续脚本读取。
```bash
lark-cli slides +xml-get --as user \
--presentation "$PID" \
--output .lark-slides/plan/$PID/readback.xml
```
成功输出中的 `data` 类似:
```json
{
"xml_presentation_id": "slides_example_presentation_id",
"path": "/abs/path/.lark-slides/plan/slides_example_presentation_id/readback.xml",
"size": 123456,
"content_saved": true,
"revision_id": 12
}
```
其中 `path` 是 CLI 解析后的绝对路径。
如果传入 `--remove-attr-id`,返回元信息中会包含 `"remove_attr_id": true`
## 输出到终端
省略 `--output`CLI 会把 XML 原文直接写到 stdout不包 JSON envelope。这个模式只适合小型演示文稿或临时调试大文件仍建议保存到文件避免终端截断。
```bash
lark-cli slides +xml-get --as user \
--presentation "$PID"
```
需要临时过滤内容时,可以用管道处理 stdout
```bash
lark-cli slides +xml-get --as user \
--presentation "$PID" \
| rg '<slide '
```
## 读取指定版本
```bash
lark-cli slides +xml-get --as user \
--presentation "$PID" \
--revision-id 10 \
--output .lark-slides/plan/$PID/readback-r10.xml
```
## 移除 XML id 属性后读取
```bash
lark-cli slides +xml-get --as user \
--presentation "$PID" \
--remove-attr-id \
--output .lark-slides/plan/$PID/readback-no-id.xml
```
注意:`--remove-attr-id` 是只读检查用的便利选项。后续如果要用 `+replace-slide` 做块级编辑,仍应保留原始 `id` / short id 信息。
## 使用建议
1. 创建或大幅改写后,用 `slides +xml-get --output` 回读全文 XML再按 `validation-checklist.md` 做页数、关键元素和静态检查。
2. 编辑已有 PPT 前,先保存一份 readback XML记录 `xml_presentation_id``slide_id``revision_id`
3. Wiki URL 可直接传给 `--presentation`CLI 会先解析出真实 Slides token。
4. 普通工作流优先保存文件;只有小型演示文稿或临时调试时才省略 `--output`
## 相关命令
- [slides +screenshot](lark-slides-screenshot.md) - 获取页面截图做视觉验证
- [slides +replace-slide](lark-slides-replace-slide.md) - 局部替换或插入页面元素
- [slides +replace-pages](lark-slides-replace-pages.md) - 多页整页重建
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 底层原生 API 参考

View File

@@ -67,7 +67,7 @@ lark-cli slides xml_presentation.slide create --as user --params '<json_params>'
</slide>
```
详细格式请参考 [xml-format-guide.md](xml-format-guide.md) 和 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
详细格式请参考 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
## 使用示例
@@ -188,7 +188,7 @@ lark-cli slides xml_presentation.slide create --as user \
4. **fill / border 写法**: 颜色填充使用 `<fill><fillColor color="..."/></fill>`,边框常用 `<border color="..." width="2"/>`
5. **插入位置**: 通过 `before_slide_id` 指定插入目标,而不是用 `position`
6. **JSON 转义**: 如果直接内联 XML需要正确转义双引号
7. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
7. **建议**: 先使用 `slides +xml-get` 获取现有结构,再添加新页面
## 批量添加建议
@@ -214,7 +214,6 @@ done
## 相关命令
- [slides +create](lark-slides-create.md) - 创建空白 PPT
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
- [slides +xml-get](lark-slides-xml-get.md) - 读取 PPT 内容并保存到本地文件
- [xml_presentation.slide delete](lark-slides-xml-presentation-slide-delete.md) - 删除幻灯片页面
- [xml-format-guide.md](xml-format-guide.md) - XML 格式详细规范
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - Schema 快速参考
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md) - XML Schema 快速参考

View File

@@ -49,7 +49,10 @@ lark-cli slides xml_presentation.slide delete --as user --params '{
```bash
# 先读取 XML 内容,确认待删除页面
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
lark-cli slides +xml-get --as user \
--presentation "slides_example_presentation_id" \
--output .lark-slides/plan/slides_example_presentation_id/readback.xml \
--json
# 然后按已知 slide_id 删除
lark-cli slides xml_presentation.slide delete --as user --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}'
@@ -119,5 +122,5 @@ done
## 相关命令
- [slides +create](lark-slides-create.md) - 创建空白 PPT
- [xml_presentations get](lark-slides-xml-presentations-get.md) - 读取 PPT 内容
- [slides +xml-get](lark-slides-xml-get.md) - 读取 PPT 内容并保存到本地文件
- [xml_presentation.slide create](lark-slides-xml-presentation-slide-create.md) - 添加幻灯片页面

View File

@@ -106,5 +106,5 @@ lark-cli slides xml_presentation.slide get --as user --params '{
- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut推荐
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
- [xml_presentations get](lark-slides-xml-presentations-get.md) — 读整个 PPT
- [slides +xml-get](lark-slides-xml-get.md) — 读整个 PPT 并保存到本地文件
- [lark-slides-edit-workflows.md](lark-slides-edit-workflows.md) — 读-改-写闭环

View File

@@ -4,7 +4,7 @@
读取飞书幻灯片PPT演示文稿的完整 XML 内容信息。
## 命令
## 底层原生命令形态
```bash
lark-cli slides xml_presentations get --as user --params '<json_params>'
@@ -35,19 +35,22 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
### 基础示例
```bash
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}'
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"slides_example_presentation_id","revision_id":-1}'
```
### 结合 jq 格式化输出
### 指定版本读取
```bash
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"slides_example_presentation_id","revision_id":10}'
```
### 保存到文件
### 移除 XML id 属性后读取
```bash
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' > presentation_data.json
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"slides_example_presentation_id","revision_id":-1,"remove_attr_id":true}'
```
## 返回值
@@ -86,10 +89,9 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
## 注意事项
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
1. 直接调用底层 API 前,使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
2. 返回的 XML 在 `data.xml_presentation.content` 字段中
3. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
4. 建议将获取的 XML 保存为文件,便于后续编辑或备份
## 相关命令

View File

@@ -12,7 +12,7 @@
4. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
5. 读取 `xml-schema-quick-ref.md``visual-planning.md``asset-planning.md`
6. 按 plan、visual planning 和 asset planning 规则逐页生成 XML`layout_type``visual_focus``text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
7. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
7. 创建 PPT 后用 `slides +xml-get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
## Plan Path
@@ -119,6 +119,34 @@ Each slide must include:
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
Optional slide fields:
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
```json
{
"chart_contract": {
"required": true,
"render_as": "native_chart",
"chart_type": "line",
"data_source": "mock_placeholder",
"data_series_required": true,
"placeholder_label_required": true,
"manual_shape_fallback_allowed": false
}
}
```
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
`data_source` must be one of:
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
@@ -183,6 +211,7 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
- `purpose`: why this asset helps the page's key message.
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
For detailed rules and examples, read `asset-planning.md`.
@@ -190,7 +219,7 @@ Good examples:
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
## XML Generation Contract
@@ -201,6 +230,7 @@ Before writing each slide XML, map the plan fields to concrete decisions:
- `visual_focus` determines the largest visual region or emphasized object.
- `text_density` caps visible text volume.
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
After creating the PPT, fetch the presentation and verify:

File diff suppressed because one or more lines are too long

View File

@@ -1,226 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" height="540" width="960">
<title>制造端智能升级</title>
<theme>
<textStyles>
<headline fontColor="rgb(255,255,255)" fontFamily="思源黑体" fontSize="36"/>
<sub-headline fontColor="rgb(229,231,235)" fontFamily="思源黑体" fontSize="20"/>
<body fontColor="rgb(229,231,235)" fontFamily="思源黑体" fontSize="16"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillColor color="linear-gradient(180deg, rgb(47, 79, 79) 0%, rgb(26, 26, 26) 100%)"/>
</fill>
</style>
<data>
<shape height="36" rotation="0" topLeftX="48" topLeftY="40" type="text" width="300">
<content>
<p>
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="28">时代背景</span>
</strong>
</p>
</content>
</shape>
<shape height="200" rotation="0" topLeftX="48" topLeftY="85" type="rect" width="864">
<fill>
<fillColor color="rgba(0,0,0,0.2)"/>
</fill>
</shape>
<img alpha="0.4" alt="十月革命场景" height="180" rotation="0" src="https://example.com/images/scene-1.png" topLeftX="48" topLeftY="95" width="288">
<crop type="rect"/>
</img>
<img alpha="0.4" alt="列宁演讲油画" height="180" rotation="0" src="https://example.com/images/scene-2.png" topLeftX="336" topLeftY="95" width="288">
<crop type="rect"/>
</img>
<img alpha="0.4" alt="十月革命战斗场面" height="180" rotation="0" src="https://example.com/images/scene-3.png" topLeftX="624" topLeftY="95" width="288">
<crop type="rect"/>
</img>
<shape height="2" rotation="0" topLeftX="90" topLeftY="180" type="rect" width="780">
<fill>
<fillColor color="rgba(255, 215, 0, 0.3)"/>
</fill>
</shape>
<shape height="10" rotation="0" topLeftX="120" topLeftY="176" type="ellipse" width="10">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
<border color="rgb(255, 215, 0)" width="1"/>
</shape>
<shape height="24" rotation="0" topLeftX="60" topLeftY="140" type="text" width="200">
<content>
<p textAlign="center">
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1917</span>
</strong>
</p>
</content>
</shape>
<shape height="22" rotation="0" topLeftX="60" topLeftY="200" type="text" width="200">
<content>
<p textAlign="center">
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">十月革命</span>
</p>
</content>
</shape>
<shape height="40" rotation="0" topLeftX="60" topLeftY="225" type="text" width="200">
<content verticalAlign="top">
<p textAlign="center">
<span color="rgb(156, 163, 175)" fontSize="12">沙皇专制终结,苏维埃政权建立</span>
</p>
</content>
</shape>
<shape height="10" rotation="0" topLeftX="475" topLeftY="176" type="ellipse" width="10">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
<border color="rgb(255, 215, 0)" width="1"/>
</shape>
<shape height="24" rotation="0" topLeftX="380" topLeftY="140" type="text" width="200">
<content>
<p textAlign="center">
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1920s</span>
</strong>
</p>
</content>
</shape>
<shape height="22" rotation="0" topLeftX="380" topLeftY="200" type="text" width="200">
<content>
<p textAlign="center">
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">国内战争</span>
</p>
</content>
</shape>
<shape height="40" rotation="0" topLeftX="380" topLeftY="225" type="text" width="200">
<content verticalAlign="top">
<p textAlign="center">
<span color="rgb(156, 163, 175)" fontSize="12">革命与反革命的残酷斗争</span>
</p>
</content>
</shape>
<shape height="10" rotation="0" topLeftX="830" topLeftY="176" type="ellipse" width="10">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
<border color="rgb(255, 215, 0)" width="1"/>
</shape>
<shape height="24" rotation="0" topLeftX="740" topLeftY="140" type="text" width="200">
<content>
<p textAlign="center">
<strong>
<span color="rgb(255, 215, 0)" fontFamily="黑体" fontSize="20">1930s</span>
</strong>
</p>
</content>
</shape>
<shape height="22" rotation="0" topLeftX="740" topLeftY="200" type="text" width="200">
<content>
<p textAlign="center">
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="16">社会主义建设</span>
</p>
</content>
</shape>
<shape height="40" rotation="0" topLeftX="740" topLeftY="225" type="text" width="200">
<content verticalAlign="top">
<p textAlign="center">
<span color="rgb(156, 163, 175)" fontSize="12">新经济政策与工业化探索</span>
</p>
</content>
</shape>
<shape height="1" rotation="0" topLeftX="48" topLeftY="300" type="rect" width="864">
<fill>
<fillColor color="rgba(255, 215, 0, 0.2)"/>
</fill>
</shape>
<shape height="24" rotation="0" topLeftX="48" topLeftY="320" type="text" width="280">
<content>
<p>
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="18">作者:奥斯特洛夫斯基</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Peoples/user.svg" topLeftX="52" topLeftY="360" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="358" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">工人家庭出身,投身革命浪潮</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Sports/torch.svg" topLeftX="52" topLeftY="390" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="388" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">战场负伤致残,生命陷入黑暗</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Health/first-aid-kit.svg" topLeftX="52" topLeftY="420" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="418" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">全身瘫痪、双目失明</span>
</p>
</content>
</shape>
<icon height="16" iconType="iconpark/Edit/edit.svg" topLeftX="52" topLeftY="450" width="16">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</icon>
<shape height="20" topLeftX="76" topLeftY="448" type="text" width="260">
<content verticalAlign="middle">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">以文学为武器,口述完成创作</span>
</p>
</content>
</shape>
<img alt="奥斯特洛夫斯基青年时期" height="213" rotation="0" src="https://example.com/images/ostrovsky.png" topLeftX="360" topLeftY="320" width="160">
<border color="rgba(255, 215, 0, 0.5)" width="2"/>
<crop type="rect"/>
</img>
<shape height="24" rotation="0" topLeftX="552" topLeftY="320" type="text" width="360">
<content>
<p>
<span color="rgb(230, 230, 230)" fontFamily="宋体" fontSize="18">创作动机</span>
</p>
</content>
</shape>
<shape height="16" rotation="0" topLeftX="552" topLeftY="350" type="rect" width="2">
<fill>
<fillColor color="rgb(196, 30, 58)"/>
</fill>
</shape>
<shape height="120" rotation="0" topLeftX="562" topLeftY="350" type="text" width="350">
<content lineSpacing="multiple:1.6" verticalAlign="top">
<p>
<span color="rgb(209, 213, 219)" fontSize="13">在双目失明、全身瘫痪的逆境中,奥斯特洛夫斯基以自身经历为蓝本,用顽强的意志口述完成了这部不朽巨著。他将文学创作视为生命的延续和战斗的武器,旨在通过保尔·柯察金的形象,向青年一代传递坚不可摧的革命信念和超越个人痛苦的崇高人生价值观。</span>
</p>
</content>
</shape>
</data>
<note>
<content>
<p>各位好,这一页将我们带回《钢铁是怎样炼成的》这部巨著诞生的波澜壮阔的时代。</p>
<p>上半部分展示了从1917年十月革命到1930年代苏联社会主义建设的宏大历史画卷。这是一个充满剧烈社会变革和残酷斗争的年代也是英雄主义和理想主义精神熊熊燃烧的年代。正是这样的背景孕育了小说的灵魂。</p>
<p>下半部分,我们聚焦于作者奥斯特洛夫斯基的个人经历。他的一生,本身就是一部比小说更震撼人心的传奇。从投身革命的青年,到因伤致残的战士,再到与命运抗争的文学巨匠。他的创作动机源于自身不屈的战斗精神,他希望用保尔的故事激励后人,在任何困境中都不要放弃理想,要将有限的生命投入到无限的为人类解放而斗争的事业中去。</p>
<p>通过了解这段历史和作者的生平,我们能更深刻地理解《钢铁是怎样炼成的》这部作品的伟大之处。</p>
</content>
</note>
</slide>
</presentation>

View File

@@ -3018,7 +3018,7 @@
子元素(mermaid 与 svg 二选一):
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
示例: &lt;mermaid&gt;&lt;![CDATA[flowchart TD\n A[开始] --&gt; B[结束]]]&gt;&lt;/mermaid&gt;
- svg: SVG 内容

View File

@@ -16,7 +16,7 @@
遇到 `invalid param`、某一页创建失败、页面空白或布局错乱时,按顺序处理:
1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
2.`xml_presentations.get` 回读,确认是否已有部分页面写入。
2.`slides +xml-get` 回读,确认是否已有部分页面写入。
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`
@@ -46,14 +46,14 @@
| 400 XML 格式错误 | XML 语法错误 | 检查标签闭合、属性引号、特殊字符转义 |
| 400 请求包装错误 | `--data` 未按 schema 包装 | 检查是否传入 `xml_presentation.content``slide.content` |
| 创建成功但页面空白 / 内容缺失 / 布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建,并在创建后立即读取 XML 验证 |
| 403 权限不足 | 身份或 scope 不匹配 | 先检查是否误用了 bot 身份,再确认 scope 和文档权限 |
| 403 权限不足 | scope 或文档权限不匹配 | 确认 scope 和文档权限;无权限时根据错误响应引导用户解决 |
| 404 演示文稿不存在 | `xml_presentation_id` 不正确或无权限 | 检查 tokenwiki URL 需先解析真实 `obj_token` |
| 404 幻灯片不存在 | `slide_id` 不正确 | 重新读取 presentation 或 slide确认最新 ID |
| 400 无法删除唯一幻灯片 | 演示文稿至少保留一页 | 先创建新页,再删除旧页 |
| 1061002 媒体上传 params error | slides 媒体上传参数不符合约定 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`slides 唯一可用 `parent_type``slide_file` |
| 1061004 forbidden | 当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限bot 常见于 PPT 非该 bot 创建 |
| 1061004 forbidden | 当前用户对演示文稿无编辑权限 | 确认当前用户对目标 PPT 有编辑权限 |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符replace 场景再看 `block_id``<content/>` |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新 `xml_presentations.get` 取最新 `revision_id` |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新 `slides +xml-get` 取最新 `revision_id` |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
## Command-Specific References

View File

@@ -7,7 +7,7 @@
## Required Flow
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`
2.`xml_presentations.get` 回读全文 XML。
2.`slides +xml-get` 回读全文 XML 到本地文件
3. 检查实际页数是否符合计划或用户要求。
4. 检查每页 `<data>` 内是否有预期主要元素。
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
@@ -19,13 +19,15 @@
回读命令:
```bash
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
lark-cli slides +xml-get --as user \
--presentation "YOUR_ID" \
--output .lark-slides/plan/<deck-or-task-id>/readback.xml \
--json
```
## Automated XML Text Overlap Lint
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查:
```bash
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
@@ -101,7 +103,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
```text
验证记录:
- 回读:已执行 xml_presentations.get实际页数 N / 预期 N。
- 回读:已执行 slides +xml-get实际页数 N / 预期 N。
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。

View File

@@ -2,7 +2,7 @@
新建演示文稿或大幅改写页面时,在 `slide_plan.json` 完成后、生成 XML 前读取本文件。目标是让 `layout_type``visual_focus``text_density` 变成实际页面几何,而不是只写在 plan 里。
默认画布按 `960 x 540` 规划。已有页面回读 XML 可以影响具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
默认画布按 `960 x 540` 规划。模板 XML 可以覆盖具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
## Core Rules
@@ -131,6 +131,7 @@ Purpose: make one metric or fact memorable.
Geometry:
- Reserve the largest object for the metric: font size often `64-110`, region at least `300 x 120`.
- Set `autoFit="normal-auto-fit"` on the metric's `<content>` so an oversized number shrinks to fit its box instead of overflowing.
- Pair the number with one explanation and optional 2-3 small supporting labels.
- Do not bury the number in a bullet list or small card.
@@ -168,7 +169,7 @@ Text:
Purpose: explain components, dependencies, or system flow.
Implementation: prefer `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` only as fallback.
Implementation: prefer Mermaid `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` as fallback.
Geometry:
- Main visual area should be a diagram, not prose.
@@ -184,7 +185,7 @@ Text:
Purpose: show operational steps, workflow, or cause-effect path.
Implementation: prefer `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` only as fallback.
Implementation: prefer Mermaid `<whiteboard>` (see `lark-slides-whiteboard.md`); use `<shape>` + `<line>` as fallback.
Geometry:
- Use numbered steps connected by arrows or lines.

View File

@@ -0,0 +1,46 @@
# 特殊视觉风格
当用户明确点名以下某种风格时,本文对应小节的规格**优先于** `visual-planning.md` 的默认审美设定;仍需遵守画布 960×540、留白、文字不溢出等硬约束。颜色一律用 `rgba(R,G,B,A)`,深浅背景都友好。
适用触发:用户说“咨询风 / 麦肯锡风 / BCG 风 / 战略咨询风 / 数据报告风”,或“豆包风 / doubao 风 / 豆包 PPT 那种”,或直接贴出属于这两类风格的参考图/链接。
---
## 咨询风McKinsey / BCG 战略咨询)
要求:一张专业、高密度的咨询演示幻灯片,设计风格需融合顶尖战略咨询公司(麦肯锡/BCG的风格与高端出版物的美学。
核心内容与布局:
- 丰富的数据可视化:幻灯片需填充复杂、精确的图表(堆叠柱状图、瀑布图或折线图)以及包含行和列的详细数据表。
- 结构化框架:包含战略图表或由细致、简洁线条构成的 2x2 矩阵。
- 高信息密度:布局精致且采用多栏设计,模仿真实的商业分析文稿,而非仅仅是一个空白的封面页。
视觉风格:
- 美学:科技极简主义,但信息承载量大。风格干净、锐利且具权威感。
- 字体:主标题使用衬线体(如 Times New Roman以营造高级财务报告的质感图表标签和数据数字使用简洁的无衬线体。
- 配色纯白背景。文字为清晰的黑色。图表和图形装饰使用深皇家蓝Deep Royal Blue和不同深浅的灰色来体现数据层级。
- 图形:表格使用极细的边框线,图表使用精确的矢量线条。
---
## 豆包风(豆包数据卡片)
要求:一张信息密度巨高的图文卡片化布局幻灯片,追求充实饱满、图文丰富、可逐行细读的版面。
核心内容与布局:
- **信息密度巨高(至关重要):除封面和分节页外,每个内容页都塞满多张图文卡片,宁可密而满,不要空而疏,不要留白。**
- 卡片化布局:方角卡片按多行网格铺满版面(网格数、图文比例按内容变化,避免每页雷同);每个卡片不是短标签,而是“标题 + 完整说明”,像浓缩的分析文稿。
- 卡片版式:卡片用窄条点缀(贴任意一边或不加),采用“锚点(语义图标或编号)+ 标题 + 细分隔线(可选)+ 正文”的结构。
- 文字卡片:内容页正文量级必须达到 400-600 中文字,分布在 4-12 个卡片,每个文字卡片都塞满文字内容,宁可密而满,不要空而疏。
- 图片卡片:内容页必须包含图片卡片,多数内容页目标 1-3 张,图片卡片与文字卡片组成网格,缺少图片卡片时必须用生图工具生成配图补足。
- 图标密集镶嵌:文字卡片、列表项和指标旁大量内嵌语义图标(部分放在圆形 / 方形底里)作为视觉锚点,让高密度文字也有图形节奏,而不是成片纯文字块。
- 数据驱动:数据页密集使用原生图表(饼图、环形图、柱状、折线、组合),图表常与卡片网格并排成半栏布局(如左图右卡或上表下卡)。
- 关键指标突出KPI 用比正文稍大的数字呈现,不要过大,下方配小字标签与简短解读。
- 防文本溢出:所有承载突出信息和密集文字的 `<content>` 必须设置 `autoFit="normal-auto-fit"`,字号会在框内自动缩排以防溢出。
视觉风格:
- 美学:干净、明亮、清爽但信息饱满;纯平无渐变,靠卡片、分隔线和对齐网格在高密度下维持秩序感。
- 字体:全篇以无衬线体(思源黑体)为主,封面或关键强调可少量使用衬线体。
- 字号:使用偏小的正文字号以容纳更多文字,提高信息密度。
- 配色控制配色数量避免色彩泛滥导致画面杂乱1 个主色1-2 个辅助色1 个强调色。

View File

@@ -1,369 +0,0 @@
# XML 格式指南
本文档基于 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 整理,说明飞书 Slides XML SchemaSML 2.0)的核心结构和常用写法。
## 基本结构
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>演示文稿标题</title>
<slide>
<style>
<fill>
<fillColor color="rgb(245, 245, 245)"/>
</fill>
</style>
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>主标题</p>
</content>
</shape>
</data>
<note>
<content textType="body">
<p>这是演讲者备注。</p>
</content>
</note>
</slide>
</presentation>
```
## 根元素
### `<presentation>`
协议标准写法应带命名空间 `http://www.larkoffice.com/sml/2.0`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证。
**属性:**
| 属性 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `width` | positiveInteger | 是 | 演示文稿宽度,如 `960` |
| `height` | positiveInteger | 是 | 演示文稿高度,如 `540` |
| `id` | string | 否 | 演示文稿标识 |
**子元素:**
| 元素 | 必需 | 说明 |
|------|------|------|
| `<title>` | 否 | 演示文稿标题 |
| `<theme>` | 否 | 全局主题 |
| `<slide>` | 是 | 幻灯片页面,至少 1 页,最多 100 页 |
## 主题
### `<theme>`
`<theme>` 当前包含两部分:
- `<background>`:演示文稿级背景填充
- `<textStyles>`:主题文本样式集合
`<textStyles>` 下可选子元素:
- `<title>`
- `<headline>`
- `<sub-headline>`
- `<body>`
- `<caption>`
这些元素定义的是主题默认样式,不是页面结构。常用属性:
| 属性 | 说明 |
|------|------|
| `fontFamily` | 字体 |
| `fontSize` | 字号 |
| `fontColor` | 字体颜色 |
## 幻灯片元素
### `<slide>`
单张幻灯片的结构比较严格。
**属性:**
| 属性 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `id` | string | 否 | 幻灯片标识 |
**直接子元素只有:**
| 元素 | 必需 | 说明 |
|------|------|------|
| `<style>` | 否 | 页面样式 |
| `<data>` | 否 | 页面元素容器 |
| `<note>` | 否 | 演讲者备注 |
这意味着 `<title>``<headline>``<body>``<caption>` 不能直接放在 `<slide>` 下。
## 文本内容模型
### `<content>`
实际页面文本通常通过 `<content>` 表达,常见位置有:
- `shape` 内部
- `table/td` 内部
- `note` 内部
**常用属性:**
| 属性 | 说明 |
|------|------|
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
| `verticalAlign` | 垂直对齐 |
| `textAlign` | 水平对齐 |
| `lineSpacing` | 行间距 |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
| `wrap` | 是否自动换行 |
**可包含的子元素:**
- `<p>`
- `<ul>`
- `<ol>`
### `<p>`
`<p>` 是段落元素,可混排纯文本和内联标签:
- `<br/>`
- `<strong>`
- `<em>`
- `<u>`
- `<span>`
- `<del>`
- `<a>`
- `<shadow>`
- `<outline>`
示例:
```xml
<content textType="body" textAlign="left">
<p>普通文本 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
<ul>
<li><p>列表项 1</p></li>
<li><p>列表项 2</p></li>
</ul>
</content>
```
## 常用页面元素
所有页面元素都放在 `<data>` 中。
### `<shape>`
`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`
```xml
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>主标题</p>
</content>
</shape>
```
```xml
<shape type="rect" topLeftX="700" topLeftY="120" width="180" height="120">
<fill>
<fillColor color="rgba(100, 149, 237, 0.25)"/>
</fill>
<border color="rgb(100, 149, 237)" width="2"/>
</shape>
```
**属性:**
| 属性 | 必需 | 说明 |
|------|------|------|
| `type` | 是 | 形状类型,`text` 表示文本框 |
| `topLeftX` | 是 | 左上角 X 坐标 |
| `topLeftY` | 是 | 左上角 Y 坐标 |
| `width` | 是 | 宽度 |
| `height` | 是 | 高度 |
| `rotation` | 否 | 旋转角度 |
| `flipX` / `flipY` | 否 | 翻转 |
| `alpha` | 否 | 透明度 |
**可选子元素:**
- `<fill>`
- `<border>`
- `<reflection>`
- `<shadow>`
- `<content>`
### `<line>`
```xml
<line startX="100" startY="200" endX="420" endY="200">
<border color="rgb(43, 47, 54)" width="2"/>
</line>
```
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`
### `<img>`
```xml
<img src="file_token_or_url" topLeftX="100" topLeftY="220" width="320" height="180"/>
```
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`
`src` 只接受两种值:
| `src` 形式 | 说明 |
|---|---|
| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX` | 通过 `slides +media-upload` 上传后返回的 token |
| `@<本地路径>`(如 `@./assets/chart.png` | **仅在 `slides +create --slides` 中可用**CLI 会自动上传该文件并替换为 file_token |
> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。
本地图片的两种姿势:
- **新建带图 PPT**`+create --slides` 里直接写 `src="@./pic.png"`CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
- **给已有 PPT 加带图新页**:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token再用 token 写进 `xml_presentation.slide create` 的 XML
### `<icon>`
```xml
<icon iconType="iconpark/Base/setting.svg" topLeftX="440" topLeftY="220" width="32" height="32"/>
```
### `<table>`
表格结构为:
- `<table>`
- `<colgroup>` / `<tr>`
- `<tr>` 内为 `<td>`
- `<td>` 内可放 `<content>`
### `<chart>`
图表元素必须至少包含:
- `<chartPlotArea>`
- `<chartData>`
同时还可以包含:
- `<chartTitle>`
- `<chartSubTitle>`
- `<chartStyle>`
- `<chartLegend>`
- `<chartTooltip>`
如果要写图表 XML建议直接以 XSD 为准,不要自行发明更简化的 chart DSL。
## 样式元素
### `<fill>`
```xml
<fill>
<fillColor color="rgb(100, 149, 237)"/>
</fill>
```
### `<border>`
```xml
<border color="rgb(0, 0, 0)" width="2" dashArray="solid"/>
```
### 颜色格式
```xml
<fillColor color="rgb(255, 0, 0)"/>
<fillColor color="rgba(255, 0, 0, 0.5)"/>
<fillColor color="linear-gradient(90deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
<fillColor color="radial-gradient(circle at 50% 50%, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
```
## 演讲者备注
### `<note>`
```xml
<note>
<content textType="body">
<p>这是演讲者备注内容。</p>
</content>
</note>
```
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>季度报告</title>
<theme>
<textStyles>
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillColor color="rgb(245, 245, 245)"/>
</fill>
</style>
<data>
<shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
<content textType="title">
<p>2024 年第一季度报告</p>
</content>
</shape>
<shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
<content textType="body">
<p>核心指标</p>
<ul>
<li><p>用户增长:+25%</p></li>
<li><p>收入增长:+30%</p></li>
<li><p>市场份额15%</p></li>
</ul>
</content>
</shape>
<shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
<fill>
<fillColor color="rgba(100, 149, 237, 0.25)"/>
</fill>
<border color="rgb(100, 149, 237)" width="2"/>
</shape>
</data>
<note>
<content textType="body">
<p>讲到增长率时补充样本范围。</p>
</content>
</note>
</slide>
</presentation>
```
## 最佳实践
1. 始终带上命名空间 `xmlns="http://www.larkoffice.com/sml/2.0"`
2.`shape type="text"` + `content` 表达页面文本
3.`topLeftX` / `topLeftY``startX` / `startY` 等 schema 中定义的属性名
4. 优先使用 `rgb` / `rgba` 颜色格式
5. 特殊字符按 XML 规则转义
6. 标准 16:9 页面建议使用 `width="960"``height="540"`
## 参考文档
- [xml-schema-quick-ref.md](xml-schema-quick-ref.md)
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
- [examples.md](examples.md)
- [slides_demo.xml](slides_demo.xml)

View File

@@ -1,6 +1,6 @@
# XML Schema 快速参考
本文档是 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 的精简版摘要;如果两者不一致,以 XSD 原文为准。
本文档是 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 的精简版摘要,并合并了常用 XML 格式写法;如果两者不一致,以 XSD 原文为准。
## 最重要的规则
@@ -30,25 +30,31 @@
| 属性 | 必需 | 说明 |
|------|------|------|
| `width` | 是 | 演示文稿宽度,正整数 |
| `height` | 是 | 演示文稿高度,正整数 |
| `width` | 是 | 演示文稿宽度,正整数,标准 16:9 页面建议使用 `960` |
| `height` | 是 | 演示文稿高度,正整数,标准 16:9 页面建议使用 `540` |
| `id` | 否 | 演示文稿标识 |
**子元素:** `<title>?`, `<theme>?`, `<slide>+`
## slide 元素
| 属性 | 必需 | 说明 |
|------|------|------|
| `id` | 否 | 幻灯片标识 |
**子元素:**
- `<style>?` - 页面样式,目前可放 `<fill>`
- `<data>?` - 页面元素容器,可放 `shape``line``polyline``img``table``icon``chart``whiteboard``undefined`
- `<note>?` - 演讲者备注,内部可放 `<content>`
`<slide>` 至少 1 页,最多 100 页。
## theme 与文本类型
`<theme>` 当前包含两部分:
- `<background>`:演示文稿级背景填充
- `<textStyles>`:主题文本样式集合
`<textStyles>` 下可选子元素包括 `<title>``<headline>``<sub-headline>``<body>``<caption>`。这些元素定义的是主题默认样式,不是页面结构。
常用属性:
| 属性 | 说明 |
|------|------|
| `fontFamily` | 字体 |
| `fontSize` | 字号 |
| `fontColor` | 字体颜色 |
XSD 中的 `title``headline``sub-headline``body``caption` 主要出现在:
- `<theme><textStyles>...</textStyles></theme>` 中,作为主题文本样式
@@ -64,6 +70,20 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
| `body` | 16 |
| `caption` | 12 |
## slide 元素
| 属性 | 必需 | 说明 |
|------|------|------|
| `id` | 否 | 幻灯片标识 |
**子元素:**
- `<style>?` - 页面样式,目前可放 `<fill>`
- `<data>?` - 页面元素容器,可放 `shape``line``polyline``img``table``icon``chart``whiteboard``undefined`
- `<note>?` - 演讲者备注,内部可放 `<content>`
这意味着 `<title>``<headline>``<body>``<caption>` 不能直接放在 `<slide>` 下。
## content 内容模型
`<content>` 可出现在 `shape``table/td``note` 中,常用属性包括:
@@ -71,12 +91,14 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
| 属性 | 说明 |
|------|------|
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
| `verticalAlign` | 垂直对齐 |
| `textAlign` | 文本对齐方式 |
| `lineSpacing` | 行间距schema 默认 `multiple:1.5` |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
| `wrap` | 是否自动换行 |
`<content>` 的子元素只能是:
@@ -84,7 +106,21 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<ul>`
- `<ol>`
### content 示例
### p 段落与内联标签
`<p>` 是段落元素,可混排纯文本和内联标签:
- `<br/>`
- `<strong>`
- `<em>`
- `<u>`
- `<span>`
- `<del>`
- `<a>`
- `<shadow>`
- `<outline>`
示例:
```xml
<content textType="body" textAlign="left">
@@ -98,8 +134,20 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
## data 常用元素
所有页面元素都放在 `<data>` 中。
### shape
`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`
```xml
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
<content textType="title">
<p>主标题</p>
</content>
</shape>
```
```xml
<shape type="rect" topLeftX="120" topLeftY="120" width="240" height="120">
<fill>
@@ -117,6 +165,16 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
| `width` | 是 | 宽度 |
| `height` | 是 | 高度 |
| `rotation` | 否 | 旋转角度 |
| `flipX` / `flipY` | 否 | 翻转 |
| `alpha` | 否 | 透明度 |
可选子元素:
- `<fill>`
- `<border>`
- `<reflection>`
- `<shadow>`
- `<content>`
### line
@@ -126,14 +184,23 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
</line>
```
`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`
### img
```xml
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
```
`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
本地图片的两种姿势:
- 新建带图 PPT`+create --slides` 里直接写 `src="@./pic.png"`CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
- 给已有 PPT 加带图新页:先 `slides +media-upload --file ./pic.png --presentation $PID` 拿 token再用 token 写进 `xml_presentation.slide create` 的 XML
> **注意**`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。
### icon
@@ -144,10 +211,85 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`iconType` 必须来自已验证的 IconPark 路径。需要语义图标时,先运行 `scripts/iconpark_tool.py search --query "<语义>"`,不要凭记忆拼路径。更多规则见 [iconpark.md](iconpark.md)。
### table
表格结构为:
- `<table>`
- `<colgroup>` / `<tr>`
- `<tr>` 内为 `<td>`
- `<td>` 内可放 `<content>`
### chart
图表元素必须至少包含:
- `<chartPlotArea>`
- `<chartData>`
同时还可以包含:
- `<chartTitle>`
- `<chartSubTitle>`
- `<chartStyle>`
- `<chartLegend>`
- `<chartTooltip>`
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例。
组合图示例:
```xml
<chart width="556" height="350" topLeftX="42" topLeftY="132">
<chartPlotArea>
<chartPlot type="combo">
<chartExtra/>
<chartSeriesList>
<chartSeries index="1" comboType="column"/>
<chartSeries index="2" comboType="line" yAxisPosition="right">
<chartTooltip format="0%"/>
</chartSeries>
</chartSeriesList>
</chartPlot>
<chartAxes>
<chartAxis type="x">
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="left">
<chartGridLine color="rgb(226, 232, 240)"/>
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="right">
<chartLabel fontSize="10" format="0%"/>
</chartAxis>
</chartAxes>
</chartPlotArea>
<chartLegend position="bottom" fontSize="11"/>
<chartData>
<dim1>
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
</dim1>
<dim2>
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
</dim2>
</chartData>
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
<chartStyle>
<chartBackground color="rgba(0, 0, 0, 0)"/>
<chartBorder color="rgb(222, 224, 227)" width="0"/>
<chartColorTheme>
<color value="rgb(28, 71, 120)"/>
<color value="rgb(240, 129, 54)"/>
</chartColorTheme>
</chartStyle>
</chart>
```
### whiteboard
```xml
<!-- SVG 模式:数据图表、装饰元素 -->
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
<svg xmlns="http://www.w3.org/2000/svg">
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>
@@ -231,12 +373,69 @@ Mermaid 模式:内容用 `<![CDATA[...]]>` 包裹,避免 `[`、`>`、`-->`
</note>
```
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>季度报告</title>
<theme>
<textStyles>
<title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
<body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
</textStyles>
</theme>
<slide>
<style>
<fill>
<fillColor color="rgb(245, 245, 245)"/>
</fill>
</style>
<data>
<shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
<content textType="title">
<p>2024 年第一季度报告</p>
</content>
</shape>
<shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
<content textType="body">
<p>核心指标</p>
<ul>
<li><p>用户增长:+25%</p></li>
<li><p>收入增长:+30%</p></li>
<li><p>市场份额15%</p></li>
</ul>
</content>
</shape>
<shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
<fill>
<fillColor color="rgba(100, 149, 237, 0.25)"/>
</fill>
<border color="rgb(100, 149, 237)" width="2"/>
</shape>
</data>
<note>
<content textType="body">
<p>讲到增长率时补充样本范围。</p>
</content>
</note>
</slide>
</presentation>
```
## 最佳实践
1. 始终带上命名空间 `xmlns="http://www.larkoffice.com/sml/2.0"`
2.`shape type="text"` + `content` 表达页面文本
3.`topLeftX` / `topLeftY``startX` / `startY` 等 schema 中定义的属性名
4. 优先使用 `rgb` / `rgba` 颜色格式;渐变必须使用 `rgba()` 且带百分比停靠点
5. 特殊字符按 XML 规则转义
6. 标准 16:9 页面建议使用 `width="960"``height="540"`
## 详细参考
- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
- [xml-format-guide.md](xml-format-guide.md)
- [examples.md](examples.md)
- [slides_demo.xml](slides_demo.xml)
- [slides_chart_demo.xml](slides_chart_demo.xml)
## Schema 版本信息

View File

@@ -3,12 +3,16 @@
from __future__ import annotations
import unittest
from pathlib import Path
import xml_text_overlap_lint
TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "assets" / "templates"
class XmlTextOverlapLintTest(unittest.TestCase):
def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None:
def assertNoXmlTextOverlapLintIssues(self, result: dict, template_path: Path) -> None:
issue_summaries = []
for slide in result.get("slides", []):
for issue in slide.get("issues", []):
@@ -21,64 +25,24 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(
result["summary"]["error_count"],
0,
f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
f"{template_path.name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
)
self.assertEqual(
result["summary"]["warning_count"],
0,
f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
f"{template_path.name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
)
def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
samples = {
"image-led-cover": """
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<style><fill><fillColor color="rgb(15,23,42)"/></fill></style>
<data>
<img src="tok" topLeftX="560" topLeftY="0" width="400" height="540"/>
<shape type="text" topLeftX="64" topLeftY="150" width="420" height="70">
<content textType="title"><p><span fontSize="42">Quarterly Review</span></p></content>
</shape>
<shape type="text" topLeftX="64" topLeftY="235" width="420" height="36">
<content textType="sub-headline"><p><span fontSize="20">Focus, progress, and next steps</span></p></content>
</shape>
</data>
</slide>
</presentation>
""",
"content-grid": """
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="60" topLeftY="44" width="620" height="46">
<content textType="title"><p><span fontSize="30">Execution Snapshot</span></p></content>
</shape>
<shape type="rect" topLeftX="60" topLeftY="126" width="250" height="150"/>
<shape type="text" topLeftX="84" topLeftY="152" width="200" height="36">
<content textType="headline"><p><span fontSize="22">Plan</span></p></content>
</shape>
<shape type="rect" topLeftX="355" topLeftY="126" width="250" height="150"/>
<shape type="text" topLeftX="379" topLeftY="152" width="200" height="36">
<content textType="headline"><p><span fontSize="22">Build</span></p></content>
</shape>
<shape type="rect" topLeftX="650" topLeftY="126" width="250" height="150"/>
<shape type="text" topLeftX="674" topLeftY="152" width="200" height="36">
<content textType="headline"><p><span fontSize="22">Launch</span></p></content>
</shape>
</data>
</slide>
</presentation>
""",
}
self.assertTrue(samples)
for sample_name, sample_xml in samples.items():
with self.subTest(sample=sample_name):
def test_xml_text_overlap_lint_accepts_all_template_xml_files(self) -> None:
template_paths = sorted(TEMPLATES_DIR.glob("*.xml"))
self.assertTrue(template_paths)
for template_path in template_paths:
with self.subTest(template=template_path.name):
result = xml_text_overlap_lint.lint_xml(
sample_xml,
sample_name,
template_path.read_text(encoding="utf-8"),
str(template_path),
)
self.assertNoXmlTextOverlapLintIssues(result, sample_name)
self.assertNoXmlTextOverlapLintIssues(result, template_path)
def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(

View File

@@ -46,7 +46,20 @@ lark-cli task +create --summary "Test Task" --dry-run
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
2. Execute `lark-cli task +create --summary "..." ...`
3. Report the result: task ID and summary.
3. Judge success by `ok == true` in the stdout JSON (the success envelope has no `code` field — do not test `code == 0`), then report the result: task ID (`data.guid`) and summary.
Example success response:
```json
{
"ok": true,
"identity": "user",
"data": {
"guid": "e297d3d0-4b60-4a5f-a4d4-xxxxxxxxxxxx",
"url": "https://applink.larkoffice.com/client/todo/detail?guid=e297d3d0-4b60-4a5f-a4d4-xxxxxxxxxxxx"
}
}
```
> [!CAUTION]
> This is a **Write Operation** -- You must confirm the user's intent before executing.

View File

@@ -1,9 +1,9 @@
# Docs CLI E2E Coverage
## Metrics
- Denominator: 8 leaf commands
- Covered: 3
- Coverage: 37.5%
- Denominator: 11 leaf commands
- Covered: 6
- Coverage: 54.5%
## Summary
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
@@ -11,6 +11,8 @@
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
@@ -20,6 +22,9 @@
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocs_HistoryWorkflow(t *testing.T) {
if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" {
t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow")
}
clie2e.SkipWithoutUserToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-docs-history-folder-" + suffix
docTitle := "lark-cli-e2e-docs-history-" + suffix
originalMarker := "original history marker " + suffix
updatedMarker := "updated history marker " + suffix
const defaultAs = "user"
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs)
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--command", "overwrite",
"--doc-format", "markdown",
"--content", "# " + docTitle + "\n\n" + updatedMarker,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
updateResult.AssertStdoutStatus(t, true)
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
fetchUpdated.AssertExitCode(t, 0)
fetchUpdated.AssertStdoutStatus(t, true)
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String()
assert.Contains(t, updatedContent, updatedMarker)
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int()
require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout)
var revertHistoryVersionID string
require.Eventually(t, func() bool {
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-list",
"--doc", docToken,
"--page-size", "20",
},
DefaultAs: defaultAs,
})
require.NoError(t, listErr)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
revisionID := entry.Get("revision_id").Int()
historyVersionID := entry.Get("history_version_id").String()
if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" {
revertHistoryVersionID = historyVersionID
return true
}
}
return false
}, 45*time.Second, 3*time.Second, "history list did not expose a prior revision")
require.NotEmpty(t, revertHistoryVersionID)
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-revert",
"--doc", docToken,
"--history-version-id", revertHistoryVersionID,
"--wait-timeout-ms", "30000",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
revertResult.AssertExitCode(t, 0)
revertResult.AssertStdoutStatus(t, true)
status := gjson.Get(revertResult.Stdout, "data.status").String()
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
statusStdout := revertResult.Stdout
if status == "running" {
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
require.Eventually(t, func() bool {
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-revert-status",
"--doc", docToken,
"--task-id", taskID,
},
DefaultAs: defaultAs,
})
require.NoError(t, statusErr)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
statusStdout = statusResult.Stdout
status = gjson.Get(statusResult.Stdout, "data.status").String()
return status != "" && status != "running"
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
}
require.Equal(t, "done", status, "status stdout:\n%s", statusStdout)
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
fetchReverted.AssertExitCode(t, 0)
fetchReverted.AssertStdoutStatus(t, true)
revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String()
assert.Contains(t, revertedContent, originalMarker)
assert.NotContains(t, revertedContent, updatedMarker)
}

View File

@@ -26,7 +26,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
tests := []struct {
name string
args []string
wantContains []string
wantURL string
wantParams map[string]any
wantBody map[string]any
wantExtraParam string
wantRefLabel string
}{
@@ -37,7 +40,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents",
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
},
{
name: "create api-version v1 compatibility",
@@ -47,7 +50,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents",
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
},
{
name: "fetch",
@@ -56,7 +59,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--doc", "doxcnDryRunE2E",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"},
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
},
{
@@ -68,7 +71,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--content", "<p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
},
{
name: "update reference-map",
@@ -80,7 +83,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantRefLabel: "widget-ref-value",
},
{
@@ -92,7 +95,50 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--block-id", "blkA,blkB,blkC",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
},
{
name: "history list",
args: []string{
"docs", "+history-list",
"--doc", "doxcnDryRunE2E",
"--page-size", "5",
"--page-token", "page_token_1",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories",
wantParams: map[string]any{
"page_size": 5,
"page_token": "page_token_1",
},
},
{
name: "history revert",
args: []string{
"docs", "+history-revert",
"--doc", "doxcnDryRunE2E",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert",
wantBody: map[string]any{
"history_version_id": "42",
"wait_timeout_ms": 0,
},
},
{
name: "history revert status",
args: []string{
"docs", "+history-revert-status",
"--doc", "doxcnDryRunE2E",
"--task-id", "task_1",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status",
wantParams: map[string]any{
"task_id": "task_1",
},
},
}
@@ -106,10 +152,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
result.AssertExitCode(t, 0)
combined := result.Stdout + "\n" + result.Stderr
for _, want := range []string{
tt.wantURL,
"docs_ai/v1",
} {
for _, want := range append(tt.wantContains, "docs_ai/v1") {
if !strings.Contains(combined, want) {
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
}
@@ -120,6 +163,15 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
if strings.Contains(combined, "--api-version") {
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if tt.wantURL != "" {
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
}
for key, want := range tt.wantParams {
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
}
for key, want := range tt.wantBody {
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
}
if tt.wantExtraParam != "" {
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
@@ -132,6 +184,21 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
}
}
func assertDryRunField(t *testing.T, stdout, path string, want any) {
t.Helper()
got := gjson.Get(stdout, path)
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
switch want := want.(type) {
case int:
require.Equal(t, int64(want), got.Int(), "%s in stdout:\n%s", path, stdout)
case string:
require.Equal(t, want, got.String(), "%s in stdout:\n%s", path, stdout)
default:
t.Fatalf("unsupported dry-run assertion type %T for %s", want, path)
}
}
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
// Fake creds are enough — dry-run short-circuits before any real API call.
t.Setenv("LARKSUITE_CLI_APP_ID", "app")

View File

@@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
}
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(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{
"minutes", "+speaker-replace",
"--minute-token", "obcnexampleminute",
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output)
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output)
assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output)
}