Compare commits

...

7 Commits

Author SHA1 Message Date
liangshuo-1
ef89d1fd40 chore(release): v1.0.33 (#952)
Change-Id: Iea77769a6a0f4e77e8946b72ddb619782be3ea42
2026-05-18 22:25:05 +08:00
JackZhao10086
c8b9809f96 Revert "feat(auth): add QR code support for device auth flow (#942)" (#950)
This reverts commit 7af616b9e5.
2026-05-18 22:12:03 +08:00
wangweiming-01
de00343063 feat: add markdown +patch shortcut (#857)
* feat: add markdown +patch shortcut

Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224

* fix: align markdown patch validation and dry-run

Change-Id: I98079901e980b74998938afc4917b91a79689948
2026-05-18 20:54:11 +08:00
ethan-zhx
67b16c5ec3 feat(slides): improve slide planning and validation guidance (#847)
refactor(slides): rename slide layout lint scope

Change-Id: I1b0e42b6508ec2c5f6ae6dc0d1b7ac23c5bbe2e3

feat(slides): improve lark slides skill guidance

Change-Id: I49563da4ca623a89f5391f36ceb8f5a31417e321

feat(slides): strengthen lark slides planning guidance

Change-Id: If49330e1f9b779bc76a919565ed61a31c255f508

feat(slides): remove lark slides layout lint rules

Change-Id: I64f1fc3b33d05c069c9ef58e61d00aa57ac18ecd

refactor(slides): streamline skill guidance

Change-Id: I3b39faaab7dcac52fac1572590fc5d8934428da5

feat(slides): add slides asset planning guidance

Change-Id: I37303043f7704e4ba484552158390a4e24bf9c42

feat(slides): add visual planning guidance

Change-Id: Idee7c392d41ff02124313d572c547d0a086d9c35

feat(slides): add lark slides planning layer

Change-Id: I3f0765aa53656070d9ba9b388dade19355e7bc6f
2026-05-18 20:44:50 +08:00
JackZhao10086
7af616b9e5 feat(auth): add QR code support for device auth flow (#942)
* feat(auth): add QR code support for device auth flow

* docs: update login QR code display hints for AI agent

* feat(auth): add ASCII QR code support for auth flow

* docs: add comments for login and auth helper functions

* chore: remove unused qrCodeToBase64 helper function

* fix(auth/login): clarify verification_url handling in login hint
2026-05-18 20:17:15 +08:00
fangshuyu-768
df4b657737 feat(drive): add +sync workflow for Drive directories (#873)
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
2026-05-18 19:56:43 +08:00
史启明(QimingShi)
4b721c0410 fix(sheets): explicitly document safe JSON unmarshal ignore in DryRun (#935)
Two DryRun functions in the sheets shortcuts called json.Unmarshal without
checking the return value. This looks like a bug, but Validate already
parses and validates the same --style / --data JSON before DryRun runs,
so the error is structurally impossible at this point.

Use _ = assignment + comment to silence the unchecked-error lint warning
and make the safety invariant explicit to future readers.

Co-authored-by: KhanCold <KhanCold@users.noreply.github.com>
2026-05-18 17:34:18 +08:00
38 changed files with 6622 additions and 464 deletions

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.0.33] - 2026-05-18
### Features
- **markdown**: Add `+patch` shortcut (#857)
- **slides**: Improve slide planning and validation guidance (#847)
- **drive**: Add `+sync` workflow for Drive directories (#873)
- **drive**: Add drive version shortcut (#841)
- **extension**: Plugin / Hook framework with command pruning (#910)
### Bug Fixes
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
- **base**: Mark base field update high risk (#936)
- **auth**: Guide agents to yield during auth device flow (#933)
### Documentation
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
### Tests
- Drop stale e2e `--yes` flags (#920)
## [v1.0.32] - 2026-05-15
### Features
@@ -721,6 +745,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30

View File

@@ -28,7 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
@@ -132,7 +132,7 @@ lark-cli auth status
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |

View File

@@ -28,7 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -133,7 +133,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

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

View File

@@ -33,6 +33,26 @@ func GetFloat(m map[string]interface{}, keys ...string) float64 {
return f
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.
func GetInt(m map[string]interface{}, keys ...string) int {
if len(keys) == 0 {
return 0
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
}
switch n := v[keys[len(keys)-1]].(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return 0
}
// GetBool safely extracts a bool.
func GetBool(m map[string]interface{}, keys ...string) bool {
if len(keys) == 0 {

View File

@@ -64,6 +64,32 @@ func TestGetFloat(t *testing.T) {
}
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"count": 42,
"json_count": 7.0,
"data": map[string]interface{}{
"score": int64(99),
},
}
if got := GetInt(m, "count"); got != 42 {
t.Errorf("GetInt(count) = %d, want 42", got)
}
if got := GetInt(m, "json_count"); got != 7 {
t.Errorf("GetInt(json_count) = %d, want 7", got)
}
if got := GetInt(m, "data", "score"); got != 99 {
t.Errorf("GetInt(data.score) = %d, want 99", got)
}
if got := GetInt(m, "missing"); got != 0 {
t.Errorf("GetInt(missing) = %d, want 0", got)
}
if got := GetInt(m); got != 0 {
t.Errorf("GetInt() = %d, want 0", got)
}
}
func TestGetBool(t *testing.T) {
m := map[string]interface{}{
"active": true,

View File

@@ -17,6 +17,8 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// driveStatusScopedTokenResolver returns a token with caller-controlled scopes
@@ -804,3 +806,59 @@ func TestDriveStatusRejectsMalformedFolderToken(t *testing.T) {
t.Fatalf("error must reference --folder-token, got: %v", err)
}
}
func TestWalkLocalForStatusMissingRootReturnsInternalError(t *testing.T) {
missingRoot := filepath.Join(t.TempDir(), "does-not-exist")
_, err := walkLocalForStatus(missingRoot, t.TempDir())
if err == nil {
t.Fatal("expected walkLocalForStatus() to fail for missing root")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "io" {
t.Fatalf("expected io error detail, got %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "walk") {
t.Fatalf("expected walk-related error, got: %v", err)
}
}
func TestHashLocalForStatusWrapsOpenError(t *testing.T) {
config := driveTestConfig()
f, _, _, _ := cmdutil.TestFactory(t, config)
runtime := common.TestNewRuntimeContext(&cobra.Command{Use: "drive"}, config)
runtime.Factory = f
_, err := hashLocalForStatus(runtime, "missing.txt")
if err == nil {
t.Fatal("expected hashLocalForStatus() to fail for missing file")
}
if !strings.Contains(err.Error(), "missing.txt") {
t.Fatalf("expected error to mention the missing file, got: %v", err)
}
}
func TestHashRemoteForStatusReturnsNetworkErrorWhenDownloadFails(t *testing.T) {
config := driveTestConfig()
f, _, _, _ := cmdutil.TestFactory(t, config)
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "drive"}, config)
runtime.Factory = f
_, err := hashRemoteForStatus(context.Background(), runtime, "tok_missing")
if err == nil {
t.Fatal("expected hashRemoteForStatus() to fail when the download request has no stub")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected structured ExitError, got %T", err)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("expected network detail, got %#v", exitErr.Detail)
}
if !strings.Contains(err.Error(), "download") {
t.Fatalf("expected download-related error, got: %v", err)
}
}

View File

@@ -0,0 +1,650 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveSyncOnConflictLocalWins = "local-wins"
driveSyncOnConflictRemoteWins = "remote-wins"
driveSyncOnConflictKeepBoth = "keep-both"
driveSyncOnConflictAsk = "ask"
)
type driveSyncItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Direction string `json:"direction,omitempty"` // "pull" or "push"
Error string `json:"error,omitempty"`
}
// DriveSync performs a two-way sync between a local directory and a Drive
// folder. It computes a diff (like +status), then:
// - new_remote → pull (download to local)
// - new_local → push (upload to Drive)
// - modified → resolve by --on-conflict strategy:
// local-wins: push local over remote;
// remote-wins: pull remote over local;
// keep-both: rename the local file with a hash suffix and pull the remote;
// ask: prompt the user per conflict.
var DriveSync = common.Shortcut{
Service: "drive",
Command: "+sync",
Description: "Two-way sync between a local directory and a Drive folder",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly"},
ConditionalScopes: []string{
"drive:file:download",
"drive:file:upload",
"space:folder:create",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "Drive folder token", Required: true},
{Name: "on-conflict", Desc: "conflict resolution when both sides modified a file", Default: driveSyncOnConflictRemoteWins, Enum: []string{driveSyncOnConflictLocalWins, driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth, driveSyncOnConflictAsk}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "quick", Type: "bool", Desc: "use best-effort modified_time comparison instead of SHA-256 hash; mismatched timestamps can still trigger real sync writes"},
},
Tips: []string{
"Two-way sync: new remote files are pulled, new local files are pushed, and conflicts (both sides modified) are resolved by --on-conflict.",
"Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.",
"Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).",
"Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.",
"Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Compute diff between --local-dir and --folder-token, then pull new/modified-remote files, push new/modified-local files, and resolve conflicts by --on-conflict strategy.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
onConflict := strings.TrimSpace(runtime.Str("on-conflict"))
if onConflict == "" {
onConflict = driveSyncOnConflictRemoteWins
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
quick := runtime.Bool("quick")
if !quick {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)
}
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)
}
// --- Phase 1: Compute diff (same logic as +status) ---
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// A local regular file at the same rel_path as a remote
// folder/docx/shortcut is a type conflict: +sync would
// classify it as new_local and attempt to upload, which either
// fails at the API or leaves the remote in a broken state
// (same rel_path with mixed types). Detect early and hard-fail.
// Symmetrically, a local directory at the same rel_path as a
// remote file/docx/shortcut would attempt create_folder and
// produce the same broken mixed-type state.
var typeConflicts []string
for _, entry := range entries {
if entry.Type == driveTypeFile {
continue
}
if _, hasLocal := localFiles[entry.RelPath]; hasLocal {
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local file vs remote %s", entry.RelPath, entry.Type))
}
}
// Check local directories vs remote non-folder entries.
// localDirs is not available yet (walked later), so check
// the filesystem directly for the subset of remote paths
// that are non-folder.
for _, entry := range entries {
if entry.Type == driveTypeFolder {
continue
}
dirPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
if info, err := os.Stat(dirPath); err == nil && info.IsDir() { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
typeConflicts = append(typeConflicts, fmt.Sprintf("%q: local directory vs remote %s", entry.RelPath, entry.Type))
}
}
if len(typeConflicts) > 0 {
return output.ErrValidation("+sync cannot proceed: path type conflict — %s; remove the local entry or the remote entry and retry", strings.Join(typeConflicts, "; "))
}
// Build the exact remote-file views that later execution will use so the
// diff phase classifies files against the same duplicate-resolution choice.
pullRemoteFiles, _, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
remoteEntriesForPush, remoteFolders, _, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
remoteFiles := driveSyncStatusRemoteFiles(pullRemoteFiles)
paths := mergeStatusPaths(localFiles, remoteFiles)
var newLocal, newRemote, modified []driveStatusEntry
var unchanged []driveStatusEntry
for _, relPath := range paths {
localFile, hasLocal := localFiles[relPath]
remoteFile, hasRemote := remoteFiles[relPath]
switch {
case hasLocal && !hasRemote:
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
case !hasLocal && hasRemote:
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
default:
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
if quick {
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
continue
}
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
if err != nil {
return err
}
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
if err != nil {
return err
}
if localHash == remoteHash {
unchanged = append(unchanged, entry)
} else {
modified = append(modified, entry)
}
}
}
detection := driveStatusDetectionExact
if quick {
detection = driveStatusDetectionQuick
}
fmt.Fprintf(runtime.IO().ErrOut, "Diff: %d new_local, %d new_remote, %d modified, %d unchanged (detection=%s)\n",
len(newLocal), len(newRemote), len(modified), len(unchanged), detection)
conflictResolutions := make(map[string]string, len(modified))
if onConflict == driveSyncOnConflictAsk && len(modified) > 0 && runtime.IO().In == nil {
return output.ErrValidation("--on-conflict=ask requires interactive stdin when modified files exist")
}
for _, entry := range modified {
resolved := onConflict
if resolved == driveSyncOnConflictAsk {
resolved, err = driveSyncAskConflict(entry.RelPath, runtime)
if err != nil {
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": 0,
"pushed": 0,
"skipped": 0,
"failed": 1,
},
"items": []driveSyncItem{{
RelPath: entry.RelPath,
FileToken: entry.FileToken,
Action: "failed",
Direction: "conflict",
Error: err.Error(),
}},
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: fmt.Sprintf("cannot collect conflict decisions for +sync: %v", err),
Detail: payload,
},
}
}
}
conflictResolutions[entry.RelPath] = resolved
}
// --- Phase 2: Execute sync operations ---
var pulled, pushed, skipped, failed int
items := make([]driveSyncItem, 0)
if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) {
if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil {
return err
}
}
plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions)
if len(plannedUploads) > 0 {
if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil {
return err
}
}
// Build push infrastructure: local walk for push + remote views + folder cache.
folderCache := map[string]string{"": folderToken}
for relDir, entry := range remoteFolders {
folderCache[relDir] = entry.FileToken
}
// Walk local filesystem early so we can include empty directories
// in the scope preflight (they also need space:folder:create).
pushLocalFiles, localDirs, err := drivePushWalkLocal(safeRoot, cwdCanonical)
if err != nil {
return err
}
if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) {
if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil {
return err
}
}
// Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped.
for _, relDir := range localDirs {
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue
}
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
pushed++
}
// 2a. Pull new_remote files.
for _, entry := range newRemote {
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
// Non-file type (doc, shortcut, etc.) — skip.
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
}
// 2b. Push new_local files.
for _, entry := range newLocal {
localFile, ok := pushLocalFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"})
skipped++
continue
}
parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()})
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
if upErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"})
pushed++
}
// 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified {
remoteFile := remoteFiles[entry.RelPath]
localFile, hasLocal := pushLocalFiles[entry.RelPath]
if !hasLocal {
// Should not happen — modified means both sides exist.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "local file disappeared during sync"})
skipped++
continue
}
resolved := conflictResolutions[entry.RelPath]
if resolved == "" {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: "user skipped"})
skipped++
continue
}
switch resolved {
case driveSyncOnConflictRemoteWins:
// Pull remote over local.
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: "remote file not found in pull views"})
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
case driveSyncOnConflictLocalWins:
// Push local over remote.
existingToken := remoteFile.FileToken
if existingToken == "" {
if chosen, ok := remoteEntriesForPush[entry.RelPath]; ok {
existingToken = chosen.FileToken
}
}
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()})
failed++
continue
}
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
if upErr != nil {
// Token contract on overwrite failure (same as +push):
// a partial-success response can return a non-empty
// file_token alongside an error. Prefer the freshly
// returned token when one was produced, fall back to
// existingToken otherwise.
failedToken := token
if failedToken == "" {
failedToken = existingToken
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"})
pushed++
case driveSyncOnConflictKeepBoth:
// Rename the local file with a hash suffix, then pull the remote.
// Use the remote file token to generate a stable suffix (same
// pattern as +pull --on-duplicate-remote=rename).
occupied := occupiedRemotePaths(entries)
// Add current local paths to occupied set so the renamed
// local file doesn't collide with an existing file or directory.
for p := range pushLocalFiles {
occupied[p] = struct{}{}
}
for _, relDir := range localDirs {
occupied[relDir] = struct{}{}
}
suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied)
if err != nil {
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()})
failed++
continue
}
// Rename the local file.
oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath))
newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel))
if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)})
failed++
continue
}
occupied[suffixedRel] = struct{}{}
// Now pull the remote version to the original path.
targetFile, ok := pullRemoteFiles[entry.RelPath]
if !ok {
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := "remote file not found in pull views after rename"
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg})
failed++
continue
}
target := filepath.Join(rootRelToCwd, entry.RelPath)
if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil {
rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath)
errMsg := err.Error()
if rollbackErr != nil {
errMsg += "; rollback failed: " + rollbackErr.Error()
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg})
failed++
continue
}
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"})
items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"})
pulled++
default:
items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "conflict", Error: fmt.Sprintf("unknown conflict strategy: %s", resolved)})
skipped++
}
}
payload := map[string]interface{}{
"detection": detection,
"diff": map[string]interface{}{
"new_local": emptyIfNil(newLocal),
"new_remote": emptyIfNil(newRemote),
"modified": emptyIfNil(modified),
"unchanged": emptyIfNil(unchanged),
},
"summary": map[string]interface{}{
"pulled": pulled,
"pushed": pushed,
"skipped": skipped,
"failed": failed,
},
"items": items,
}
if failed > 0 {
msg := fmt.Sprintf("%d item(s) failed during +sync", failed)
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "partial_failure",
Message: msg,
Detail: payload,
},
}
}
runtime.Out(payload, nil)
return nil
},
}
func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[string]driveStatusRemoteFile {
remoteFiles := make(map[string]driveStatusRemoteFile, len(pullRemoteFiles))
for relPath, target := range pullRemoteFiles {
fileToken := target.ItemFileToken
if fileToken == "" {
fileToken = target.DownloadToken
}
remoteFiles[relPath] = driveStatusRemoteFile{FileToken: fileToken, ModifiedTime: target.ModifiedTime}
}
return remoteFiles
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the
// user chose to skip.
func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (string, error) {
fmt.Fprintf(runtime.IO().ErrOut, "CONFLICT: both sides modified %q. Choose: [R]emote-wins / [L]ocal-wins / [K]eep-both / [S]kip (default: R): ", relPath)
if runtime.IO().In == nil {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin is not available", relPath)
}
reader, ok := runtime.IO().In.(*bufio.Reader)
if !ok {
reader = bufio.NewReader(runtime.IO().In)
runtime.IO().In = reader
}
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot read conflict choice for %q: %s", relPath, err)
}
answer := strings.TrimSpace(strings.ToLower(line))
if answer == "" {
if errors.Is(err, io.EOF) {
return "", output.ErrValidation("cannot resolve conflict for %q with --on-conflict=ask: stdin reached EOF before any choice was provided", relPath)
}
return driveSyncOnConflictRemoteWins, nil
}
switch answer {
case "l", "local", "local-wins":
return driveSyncOnConflictLocalWins, nil
case "k", "keep", "keep-both":
return driveSyncOnConflictKeepBoth, nil
case "s", "skip":
return "", nil
case "r", "remote", "remote-wins":
return driveSyncOnConflictRemoteWins, nil
default:
return "", output.ErrValidation("invalid conflict choice for %q: %q (expected one of remote/local/keep/skip)", relPath, strings.TrimSpace(line))
}
}
func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool {
if len(newRemote) > 0 {
return true
}
for _, entry := range modified {
switch conflictResolutions[entry.RelPath] {
case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth:
return true
}
}
return false
}
func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string {
planned := make([]string, 0, len(newLocal)+len(modified))
for _, entry := range newLocal {
planned = append(planned, entry.RelPath)
}
for _, entry := range modified {
if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins {
planned = append(planned, entry.RelPath)
}
}
return planned
}
func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool {
for _, relPath := range uploadPaths {
parentRel := drivePushParentRel(relPath)
if parentRel == "" {
continue
}
if _, ok := folderCache[parentRel]; !ok {
return true
}
}
// Empty local directories also need create_folder if not already on Drive.
for _, relDir := range localDirs {
if _, ok := folderCache[relDir]; !ok {
return true
}
}
return false
}
func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error {
if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
if info.IsDir() {
return output.Errorf(output.ExitInternal, "rollback", "original path became a directory during rollback: %s", oldAbsPath)
}
if err := os.Remove(oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "remove partial restored path %q: %s", oldAbsPath, err)
}
} else if !os.IsNotExist(err) {
return output.Errorf(output.ExitInternal, "rollback", "stat original path %q during rollback: %s", oldAbsPath, err)
}
if err := os.Rename(newAbsPath, oldAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated.
return output.Errorf(output.ExitInternal, "rollback", "restore renamed local file %q: %s", oldAbsPath, err)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ func Shortcuts() []common.Shortcut {
DriveStatus,
DrivePush,
DrivePull,
DriveSync,
DriveTaskResult,
DriveApplyPermission,
DriveSearch,

View File

@@ -28,6 +28,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+status",
"+push",
"+pull",
"+sync",
"+task_result",
"+apply-permission",
"+search",

View File

@@ -5,6 +5,7 @@ package markdown
import (
"bytes"
"context"
"errors"
"fmt"
"io"
@@ -112,6 +113,42 @@ func finalMarkdownFileName(spec markdownUploadSpec) string {
return filepath.Base(spec.FilePath)
}
func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markdownUploadSpec) (string, error) {
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, spec.FileToken)
if err != nil {
return "", err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = spec.FileToken + ".md"
}
return fileName, nil
}
func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, error) {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
}
return resp, nil
}
func validateNonEmptyMarkdownSize(size int64) error {
if size == 0 {
return output.ErrValidation("%s", markdownEmptyContentError)
}
return nil
}
func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) {
var size int64
if spec.ContentSet {
@@ -127,8 +164,8 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec)
}
size = info.Size()
}
if size == 0 {
return 0, output.ErrValidation("%s", markdownEmptyContentError)
if err := validateNonEmptyMarkdownSize(size); err != nil {
return 0, err
}
return size, nil
}

View File

@@ -6,7 +6,6 @@ package markdown
import (
"context"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -73,19 +72,9 @@ var MarkdownOverwrite = common.Shortcut{
return err
}
fileName := strings.TrimSpace(spec.FileName)
if fileName == "" && spec.FileSet {
fileName = filepath.Base(spec.FilePath)
}
if fileName == "" {
remoteName, err := fetchMarkdownFileName(runtime, fileToken)
if err != nil {
return err
}
fileName = strings.TrimSpace(remoteName)
}
if fileName == "" {
fileName = fileToken + ".md"
fileName, err := resolveMarkdownOverwriteFileName(runtime, spec)
if err != nil {
return err
}
spec.FileName = fileName

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"context"
"fmt"
"io"
"regexp"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
markdownPatchModeLiteral = "literal"
markdownPatchModeRegex = "regex"
)
type markdownPatchSpec struct {
FileToken string
Pattern string
Content string
ContentSet bool
Regex bool
}
var MarkdownPatch = common.Shortcut{
Service: "markdown",
Command: "+patch",
Description: "Patch a Markdown file in Drive via fetch-local-replace-overwrite",
Risk: "write",
Scopes: []string{"drive:file:download", "drive:file:upload", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "file-token", Desc: "target Markdown file token", Required: true},
{Name: "pattern", Desc: "literal text or RE2 regex to match", Input: []string{common.File, common.Stdin}},
{Name: "content", Desc: "replacement Markdown content", Input: []string{common.File, common.Stdin}},
{Name: "regex", Type: "bool", Desc: "interpret --pattern as RE2 regular expression"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newMarkdownPatchSpec(runtime)
if err := validateMarkdownPatchSpec(runtime, spec); err != nil {
return err
}
if spec.Regex {
if _, err := regexp.Compile(spec.Pattern); err != nil {
return output.ErrValidation("invalid --pattern regex: %s", err)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newMarkdownPatchSpec(runtime)
mode := markdownPatchModeLiteral
if spec.Regex {
mode = markdownPatchModeRegex
}
sizeThreshold := common.FormatSize(markdownSinglePartSizeLimit)
return common.NewDryRunAPI().
Desc("Download the current Markdown file, apply the replacement locally, and overwrite the file only when matches are found").
GET("/open-apis/drive/v1/files/:file_token/download").
Desc("[1] Download the current Markdown content").
Set("file_token", spec.FileToken).
POST("/open-apis/drive/v1/metas/batch_query").
Desc("[2] Read current file metadata to preserve the existing file name before overwrite").
Body(map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": spec.FileToken,
"doc_type": "file",
},
},
}).
POST("/open-apis/drive/v1/files/upload_all").
Desc("[3a] If the patched Markdown is at most "+sizeThreshold+", overwrite the file with multipart/form-data upload_all").
Body(map[string]interface{}{
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
"parent_type": "explorer",
"parent_node": "",
"size": "<updated_size_bytes>",
"file": "<patched_markdown_content>",
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_prepare").
Desc("[3b] If the patched Markdown exceeds "+sizeThreshold+", initialize multipart overwrite upload").
Body(map[string]interface{}{
"file_name": "<existing_remote_name_or_" + spec.FileToken + ".md>",
"parent_type": "explorer",
"parent_node": "",
"size": "<updated_size_bytes>",
"file_token": spec.FileToken,
}).
POST("/open-apis/drive/v1/files/upload_part").
Desc("[3c] Upload file parts (repeated) when multipart overwrite is required").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/files/upload_finish").
Desc("[3d] Finalize multipart overwrite upload and return the new version").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
}).
Set("mode", mode)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newMarkdownPatchSpec(runtime)
resp, err := openMarkdownDownload(ctx, runtime, spec.FileToken)
if err != nil {
return err
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
return output.ErrNetwork("download failed: %s", err)
}
original := string(payload)
patched, matchCount, err := applyMarkdownPatch(original, spec)
if err != nil {
return err
}
mode := markdownPatchModeLiteral
if spec.Regex {
mode = markdownPatchModeRegex
}
out := map[string]interface{}{
"updated": false,
"mode": mode,
"match_count": matchCount,
"version": "",
"size_bytes_before": len(payload),
"size_bytes_after": len(payload),
}
if matchCount == 0 {
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownPatch(w, out)
})
return nil
}
patchedPayload := []byte(patched)
if err := validateNonEmptyMarkdownSize(int64(len(patchedPayload))); err != nil {
return err
}
specUpload := markdownUploadSpec{
FileToken: spec.FileToken,
}
fileName, err := resolveMarkdownOverwriteFileName(runtime, specUpload)
if err != nil {
return err
}
specUpload.FileName = fileName
result, err := uploadMarkdownContent(runtime, specUpload, patchedPayload)
if err != nil {
return err
}
out["updated"] = true
out["version"] = result.Version
out["size_bytes_after"] = len(patchedPayload)
runtime.OutFormat(out, nil, func(w io.Writer) {
prettyPrintMarkdownPatch(w, out)
})
return nil
},
}
func newMarkdownPatchSpec(runtime *common.RuntimeContext) markdownPatchSpec {
return markdownPatchSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
Pattern: runtime.Str("pattern"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
Regex: runtime.Bool("regex"),
}
}
func validateMarkdownPatchSpec(runtime *common.RuntimeContext, spec markdownPatchSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if !runtime.Changed("pattern") {
return common.FlagErrorf("--pattern is required")
}
if spec.Pattern == "" {
return output.ErrValidation("--pattern cannot be empty")
}
if !spec.ContentSet {
return common.FlagErrorf("--content is required")
}
return nil
}
func applyMarkdownPatch(original string, spec markdownPatchSpec) (string, int, error) {
if !spec.Regex {
return strings.ReplaceAll(original, spec.Pattern, spec.Content), strings.Count(original, spec.Pattern), nil
}
re, err := regexp.Compile(spec.Pattern)
if err != nil {
return "", 0, output.ErrValidation("invalid --pattern regex: %s", err)
}
matches := re.FindAllStringIndex(original, -1)
return re.ReplaceAllString(original, spec.Content), len(matches), nil
}
func prettyPrintMarkdownPatch(w io.Writer, data map[string]interface{}) {
updated := common.GetBool(data, "updated")
if updated {
io.WriteString(w, "updated: true\n")
} else {
io.WriteString(w, "updated: false\n")
}
io.WriteString(w, "mode: "+common.GetString(data, "mode")+"\n")
fmt.Fprintf(w, "match_count: %d\n", common.GetInt(data, "match_count"))
if version := common.GetString(data, "version"); version != "" {
io.WriteString(w, "version: "+version+"\n")
}
fmt.Fprintf(w, "size_bytes_before: %d\n", common.GetInt(data, "size_bytes_before"))
fmt.Fprintf(w, "size_bytes_after: %d\n", common.GetInt(data, "size_bytes_after"))
}

View File

@@ -0,0 +1,564 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package markdown
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestMarkdownPatchValidation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
tests := []struct {
name string
args []string
want string
}{
{
name: "pattern is required",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--content", "DONE",
},
want: "--pattern is required",
},
{
name: "pattern cannot be empty",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "",
"--content", "DONE",
},
want: "--pattern cannot be empty",
},
{
name: "content is required",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
},
want: "--content is required",
},
{
name: "invalid regex",
args: []string{
"+patch",
"--file-token", "box_md_patch",
"--regex",
"--pattern", "(",
"--content", "DONE",
},
want: "invalid --pattern regex",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := mountAndRunMarkdown(t, MarkdownPatch, tt.args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected error containing %q, got %v", tt.want, err)
}
})
}
}
func TestMarkdownPatchDryRunLiteral(t *testing.T) {
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", "TODO", "DONE", false)
if got := dry.Mode; got != markdownPatchModeLiteral {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
}
if got := len(dry.API); got != 6 {
t.Fatalf("api steps = %d, want 6", got)
}
if got := dry.API[0].URL; got != "/open-apis/drive/v1/files/box_md_patch/download" {
t.Fatalf("download url = %q", got)
}
if got := dry.API[1].URL; got != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metas url = %q", got)
}
if got := dry.API[2].URL; got != "/open-apis/drive/v1/files/upload_all" {
t.Fatalf("upload_all url = %q", got)
}
if got := dry.API[3].URL; got != "/open-apis/drive/v1/files/upload_prepare" {
t.Fatalf("upload_prepare url = %q", got)
}
if got := dry.API[4].URL; got != "/open-apis/drive/v1/files/upload_part" {
t.Fatalf("upload_part url = %q", got)
}
if got := dry.API[5].URL; got != "/open-apis/drive/v1/files/upload_finish" {
t.Fatalf("upload_finish url = %q", got)
}
if got := dry.API[2].Body["file_token"]; got != "box_md_patch" {
t.Fatalf("upload_all file_token = %#v", got)
}
if got := dry.API[3].Body["file_token"]; got != "box_md_patch" {
t.Fatalf("upload_prepare file_token = %#v", got)
}
if got := dry.API[2].Body["file"]; got != "<patched_markdown_content>" {
t.Fatalf("upload_all file placeholder = %#v", got)
}
}
func TestMarkdownPatchDryRunRegex(t *testing.T) {
dry := decodeMarkdownPatchDryRun(t, "box_md_patch", `Version: ([0-9]+)`, `Version: $1`, true)
if got := dry.Mode; got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := dry.API[0].Desc; !strings.Contains(got, "Download the current Markdown content") {
t.Fatalf("download desc = %q", got)
}
if got := dry.API[3].Desc; !strings.Contains(got, "multipart overwrite upload") {
t.Fatalf("upload_prepare desc = %q", got)
}
if got := dry.API[5].Body["block_num"]; got != "<block_num>" {
t.Fatalf("upload_finish block_num = %#v", got)
}
}
func TestValidateMarkdownPatchSpecRejectsInvalidFileToken(t *testing.T) {
runtime := newMarkdownPatchRuntime(t, "../bad", "TODO", "DONE", false)
err := validateMarkdownPatchSpec(runtime, newMarkdownPatchSpec(runtime))
if err == nil || !strings.Contains(err.Error(), "--file-token must not contain '..' path traversal") {
t.Fatalf("expected invalid file-token error, got %v", err)
}
}
func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeMarkdownEnvelope(t, stdout)
if common.GetBool(data, "updated") {
t.Fatalf("updated = true, want false")
}
if got := common.GetString(data, "mode"); got != markdownPatchModeLiteral {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral)
}
if got := common.GetInt(data, "match_count"); got != 0 {
t.Fatalf("match_count = %d, want 0", got)
}
if got := common.GetString(data, "version"); got != "" {
t.Fatalf("version = %q, want empty", got)
}
if got := common.GetInt(data, "size_bytes_before"); got != len("# hello\n") {
t.Fatalf("size_bytes_before = %d, want %d", got, len("# hello\n"))
}
if got := common.GetInt(data, "size_bytes_after"); got != len("# hello\n") {
t.Fatalf("size_bytes_after = %d, want %d", got, len("# hello\n"))
}
if strings.Contains(stdout.String(), `"matches"`) {
t.Fatalf("stdout should not include matches field: %s", stdout.String())
}
}
func TestMarkdownPatchPrettyOutputWhenNothingMatches(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"updated: false",
"mode: literal",
"match_count: 0",
"size_bytes_before: 8",
"size_bytes_after: 8",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "version:") {
t.Fatalf("pretty output should omit version when unchanged:\n%s", out)
}
}
func TestMarkdownPatchLiteralOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\nTODO\n"),
Headers: map[string][]string{
"Content-Disposition": {`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "README.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910626",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != "box_md_patch" {
t.Fatalf("file_token = %q, want box_md_patch", got)
}
if got := body.Fields["file_name"]; got != "README.md" {
t.Fatalf("file_name = %q, want README.md", got)
}
if got := string(body.Files["file"]); got != "# DONE\nDONE\n" {
t.Fatalf("uploaded file content = %q", got)
}
data := decodeMarkdownEnvelope(t, stdout)
if !common.GetBool(data, "updated") {
t.Fatalf("updated = false, want true")
}
if got := common.GetInt(data, "match_count"); got != 2 {
t.Fatalf("match_count = %d, want 2", got)
}
if got := common.GetString(data, "version"); got != "7633658129540910626" {
t.Fatalf("version = %q, want 7633658129540910626", got)
}
if got := common.GetInt(data, "size_bytes_before"); got != len("# TODO\nTODO\n") {
t.Fatalf("size_bytes_before = %d, want %d", got, len("# TODO\nTODO\n"))
}
if got := common.GetInt(data, "size_bytes_after"); got != len("# DONE\nDONE\n") {
t.Fatalf("size_bytes_after = %d, want %d", got, len("# DONE\nDONE\n"))
}
}
func TestMarkdownPatchPrettyOutputWhenUpdated(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("# TODO\n"),
Headers: map[string][]string{
"Content-Disposition": {`attachment; filename="README.md"`},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "README.md"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "9001",
},
},
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "TODO",
"--content", "DONE",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
for _, want := range []string{
"updated: true",
"mode: literal",
"match_count: 1",
"version: 9001",
"size_bytes_before: 7",
"size_bytes_after: 7",
} {
if !strings.Contains(out, want) {
t.Fatalf("pretty output missing %q:\n%s", want, out)
}
}
}
func TestMarkdownPatchRegexOverwrite(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("Version: 12\nVersion: 34\n"),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "version.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910627",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--regex",
"--pattern", `Version: ([0-9]+)`,
"--content", `Version: $1 (patched)`,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := string(body.Files["file"]); got != "Version: 12 (patched)\nVersion: 34 (patched)\n" {
t.Fatalf("uploaded file content = %q", got)
}
data := decodeMarkdownEnvelope(t, stdout)
if got := common.GetString(data, "mode"); got != markdownPatchModeRegex {
t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex)
}
if got := common.GetInt(data, "match_count"); got != 2 {
t.Fatalf("match_count = %d, want 2", got)
}
}
func TestApplyMarkdownPatchRejectsInvalidRegex(t *testing.T) {
_, _, err := applyMarkdownPatch("hello", markdownPatchSpec{
Pattern: "(",
Content: "DONE",
Regex: true,
})
if err == nil || !strings.Contains(err.Error(), "invalid --pattern regex") {
t.Fatalf("expected invalid regex error, got %v", err)
}
}
func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello world\n"),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"title": "hello.md"},
},
},
},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_patch",
"version": "7633658129540910628",
},
},
}
reg.Register(uploadStub)
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", " world",
"--content", "",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedMultipartBody(t, uploadStub)
if got := string(body.Files["file"]); got != "hello\n" {
t.Fatalf("uploaded file content = %q", got)
}
}
func TestMarkdownPatchRejectsEmptyPatchedContent(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/box_md_patch/download",
Status: 200,
RawBody: []byte("hello\n"),
})
err := mountAndRunMarkdown(t, MarkdownPatch, []string{
"+patch",
"--file-token", "box_md_patch",
"--pattern", "hello\n",
"--content", "",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "empty markdown content is not supported") {
t.Fatalf("expected empty content validation error, got %v", err)
}
}
func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v\nstdout:\n%s", err, stdout.String())
}
return envelope.Data
}
type markdownPatchDryRunOutput struct {
Mode string `json:"mode"`
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
func newMarkdownPatchRuntime(t *testing.T, fileToken, pattern, content string, regex bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "markdown +patch"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("content", "", "")
cmd.Flags().Bool("regex", false, "")
for name, value := range map[string]string{
"file-token": fileToken,
"pattern": pattern,
"content": content,
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
if regex {
if err := cmd.Flags().Set("regex", "true"); err != nil {
t.Fatalf("set --regex: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, markdownTestConfig())
}
func decodeMarkdownPatchDryRun(t *testing.T, fileToken, pattern, content string, regex bool) markdownPatchDryRunOutput {
t.Helper()
runtime := newMarkdownPatchRuntime(t, fileToken, pattern, content, regex)
dry := MarkdownPatch.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run json: %v", err)
}
var out markdownPatchDryRunOutput
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal dry-run json: %v\njson=%s", err, string(data))
}
return out
}

View File

@@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{"+create", "+fetch", "+overwrite"}
want := []string{"+create", "+fetch", "+patch", "+overwrite"}
if len(got) != len(want) {
t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want))

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MarkdownCreate,
MarkdownFetch,
MarkdownPatch,
MarkdownOverwrite,
}
}

View File

@@ -95,7 +95,7 @@ var SheetSetStyle = common.Shortcut{
}
r := normalizePointRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
_ = json.Unmarshal([]byte(runtime.Str("style")), &style) // Validate already parses and validates this JSON.
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
Body(map[string]interface{}{
@@ -164,7 +164,7 @@ var SheetBatchSetStyle = common.Shortcut{
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
_ = json.Unmarshal([]byte(runtime.Str("data")), &data) // Validate already parses and validates this JSON via validateBatchStyleData().
normalizeBatchStyleRanges(data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").

View File

@@ -19,7 +19,7 @@ metadata:
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history``drive +version-get``drive +version-revert``drive +version-delete`;这组命令同时支持 `--as user``--as bot`,自动化场景优先 `--as bot`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`
@@ -245,6 +245,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact` or `detection=quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. |
| `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |

View File

@@ -6,7 +6,7 @@
上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。
## 快速决策
- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
## 命令

View File

@@ -1,6 +1,6 @@
---
name: lark-markdown
version: 1.0.0
version: 1.1.0
description: "飞书 Markdown查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。"
metadata:
requires:
@@ -16,6 +16,7 @@ metadata:
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch`
- 用户要对 Markdown 文件做**局部文本替换 / 正则替换**,优先使用 `lark-cli markdown +patch`
- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
@@ -28,6 +29,10 @@ metadata:
- 直接传字符串
- `@file` 从本地文件读取内容
- `-` 从 stdin 读取内容
- `markdown +patch` 的内部语义是:**先完整下载 Markdown再本地替换再整文件覆盖上传**
- `markdown +patch` 不是服务端原子 patch它是 CLI 侧编排出来的局部更新能力
- `markdown +patch` 当前只支持**单组** `--pattern` / `--content`
- `markdown +patch` 替换后的最终内容**不能为空**;如果替换后整篇 Markdown 变成空字符串CLI 会直接报错,不会上传空文件
- `--file` 只接受本地 `.md` 文件路径
## Shortcuts推荐优先使用
@@ -38,6 +43,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |
| [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive |
| [`+patch`](references/lark-markdown-patch.md) | Patch a Markdown file in Drive via fetch-local-replace-overwrite |
| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive |
## 参考

View File

@@ -0,0 +1,135 @@
# markdown +patch
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
对 Drive 中已有的原生 Markdown 文件做局部文本替换,并返回是否实际写入了新版本。
## 命令
```bash
# 字面量替换
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern 'hello markdown' \
--content 'hello patched'
# 正则替换RE2
lark-cli markdown +patch \
--file-token boxcnxxxx \
--regex \
--pattern 'hello (.+)' \
--content 'hi $1'
# 删除匹配内容
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern ' debug' \
--content ''
# --pattern / --content 也支持 @file
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern @./pattern.txt \
--content @./replacement.md
# 从 stdin 读取 replacement
printf 'hi patched\n' | \
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern 'hello markdown' \
--content -
# 预览底层编排
lark-cli markdown +patch \
--file-token boxcnxxxx \
--pattern 'hello markdown' \
--content 'hello patched' \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file-token` | 是 | 目标 Markdown 文件 token |
| `--pattern` | 是 | 要匹配的文本;默认按字面量处理;支持直接传字符串、`@file``-`stdin |
| `--content` | 是 | 替换后的内容;支持直接传字符串、`@file``-`stdin允许空字符串 `''`,表示删除匹配内容 |
| `--regex` | 否 | 将 `--pattern` 按 Go RE2 正则解释;`--content` 支持 `$1` 这类分组替换;如果需要字面 `$`,请写成 `$$` |
## 关键约束
- 当前只支持**单组** `--pattern` / `--content`
- `--pattern` 必须显式传入且不能为空字符串
- `--content` 必须显式传入,但允许为空字符串
- 未加 `--regex` 时,行为等价于对整份 Markdown 文本执行 `strings.ReplaceAll`
- 加了 `--regex` 时,行为等价于对整份 Markdown 文本执行 RE2 全量替换;`--content` 里的 `$1``${name}` 会按 Go regexp replacement template 解释,字面 `$` 请写成 `$$`
- 替换后的最终 Markdown 不能为空;如果 patch 结果是空字符串CLI 会直接报错,不会上传空文件
- `0` 命中时命令仍然成功返回,但不会上传新版本
## 实现边界
- 该命令的内部语义是:**download -> local replace -> overwrite upload**
- 它不是服务端原子 patch如果有人在你下载后、上传前更新了同一文件本次 patch 仍可能覆盖那次中间修改
- 它不会返回详细匹配位置,只返回命中数量
- `--dry-run` 会同时展示两种可能的上传路径:`upload_all`(小文件)和 `upload_prepare/upload_part/upload_finish`(大文件分片上传)
## 返回值
命中并写入新版本:
```json
{
"ok": true,
"identity": "user",
"data": {
"updated": true,
"mode": "literal",
"match_count": 1,
"version": "7639217385152646325",
"size_bytes_before": 39,
"size_bytes_after": 41
}
}
```
未命中:
```json
{
"ok": true,
"identity": "user",
"data": {
"updated": false,
"mode": "literal",
"match_count": 0,
"version": "",
"size_bytes_before": 41,
"size_bytes_after": 41
}
}
```
其中:
- `updated` 表示本次是否真的上传了新版本
- `mode``literal``regex`
- `match_count` 是匹配次数
- `version` 只有在 `updated=true` 时才会有值
- `size_bytes_before` / `size_bytes_after` 分别是替换前后的 Markdown 大小
## 适用场景
- 只需要替换一小段 Markdown 文本,而不想自己手动 `fetch -> edit -> overwrite`
- 需要基于正则做简单批量替换
- 需要判断“这次是否真的改到了内容”
## 不适用场景
- 需要 rename / move / delete / permission / comment 管理:切到 [`lark-drive`](../../lark-drive/SKILL.md)
- 需要多组 patch 一次完成:当前不支持,改为多次调用 `markdown +patch`
- 需要真正原子更新:当前能力不提供
## 参考
- [lark-markdown](../SKILL.md) — Markdown 域总览
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -10,16 +10,38 @@ metadata:
# slides (v1)
## Quick Reference
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-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` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**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` 实际改变页面几何、主视觉和文本量。**
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”或用户需求明显落在已有场景模板内如工作汇报、产品介绍、商业计划书、培训、晋升汇报等MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
> [!NOTE]
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。生成本地 XML 后,如可运行 PythonMUST 先用 [`scripts/layout_lint.py`](scripts/layout_lint.py) 检查 XML well-formed、重叠/越界/文本高度风险,再创建或追加页面。它不是完整 XSD schema 校验。**
**CRITICAL — 使用模板生成或改写页面时MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
@@ -41,55 +63,69 @@ lark-cli auth login --domain slides
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`
## 快速开始
一条命令创建包含页面内容的 PPT推荐
```bash
lark-cli slides +create --title "演示文稿标题" --slides '[
"<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=\"80\" width=\"800\" height=\"100\"><content textType=\"title\"><p>页面标题</p></content></shape><shape type=\"text\" topLeftX=\"80\" topLeftY=\"200\" width=\"800\" height=\"200\"><content textType=\"body\"><p>正文内容</p><ul><li><p>要点一</p></li><li><p>要点二</p></li></ul></content></shape></data></slide>"
]'
```
也可以分两步(先创建空白 PPT再逐页添加详见 [+create 参考文档](references/lark-slides-create.md)。
> [!WARNING]
> `--slides '[...]'` 适合简单页面批量创建但并不等同于“10 页以内都安全”。如果 slide XML 含中文、大段文本、复杂布局、嵌套引号或较多特殊字符shell 传参时可能出现转义或截断问题,导致内容丢失、页面空白或布局异常。遇到复杂页面时,优先改用“两步创建法”。
> [!IMPORTANT]
> `slides +create --slides` 底层是“先创建空白 PPT再逐页调用 `xml_presentation.slide.create`”。这不是原子操作中途某一页失败时前面已创建成功的页面会保留。skill 必须把这种“部分成功”风险提前告诉用户,并在失败后先记录 `xml_presentation_id`,回读确认当前状态,再决定是否在现有 PPT 上继续修复或追加。
> 以上是最小可用示例。更丰富的页面效果(渐变背景、卡片、图表、表格等),参考下方 Workflow 和 XML 模板。
## 执行前必做
> **重要**`references/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。
### 必读(每次创建前)
高频只读:
| 文档 | 说明 |
|------|------|
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML 元素和属性速查,必读** |
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写)
- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写)
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
### 选读(需要时查阅)
按需再读:
| 场景 | 文档 |
|------|------|
| 需要了解详细 XML 结构 | [xml-format-guide.md](references/xml-format-guide.md) |
| 需要快速筛模板、做低成本路由 | [`scripts/template_tool.py search`](scripts/template_tool.py) |
| 需要匹配 PPT 模板/主题风格 | [template-catalog.md](references/template-catalog.md) |
| 需要按页型抽摘要或裁切 XML 片段 | [`scripts/template_tool.py`](scripts/template_tool.py) |
| 需要做本地布局风险检查 | [`scripts/layout_lint.py`](scripts/layout_lint.py) |
| 需要 CLI 调用示例 | [examples.md](references/examples.md) |
| 需要参考真实 PPT 的 XML | [slides_demo.xml](references/slides_demo.xml) |
| 需要用 table/chart 等复杂元素 | [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml)(完整 Schema |
| 需要编辑已有 PPT 的单个页面 | [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) |
| 需要了解某个命令的详细参数 | 对应命令的 reference 文档(见下方参考文档章节) |
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
## Workflow
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
### Design Ideas
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重1-2 个辅助色承担结构和分区1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
可优先考虑这些页面形态:
- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。
- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。
- **2x2 / 2x3 网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。
- **半出血视觉**:图片或抽象形状占据左/右半屏,文字覆盖或贴边排布。
- **大数字卡片**:关键指标用 60-72pt 数字,下面配 10-14pt 标签。
- **对比列**before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。
字体和间距建议:
- 标题 36-44pt关键结论可更大正文 14-18pt注释 10-12pt。
- 正文默认左对齐;只在封面、结尾或大号数字场景中使用居中。
- 页面边距至少 40px内容块之间保持 24-40px 间距,并在同一 deck 内保持一致。
- 卡片内边距要真实留出空间,不要让文字贴边;对齐 shape 和文字时要考虑文本框 padding。
常见错误必须避免:
- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
| 场景 | 推荐方式 |
@@ -101,156 +137,40 @@ lark-cli slides +create --title "演示文稿标题" --slides '[
> [!WARNING]
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
> [!IMPORTANT]
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
### 模板与脚本优先流程
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
```bash
# 1. 搜索候选:把用户原始需求整句放进 --query不要只放手动提炼的短词
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
# 2. 锁定模板后先看页型摘要
python3 skills/lark-slides/scripts/template_tool.py summarize --template <template-id> --label <封面|目录|分节|内容|结尾>
# 3. 只有需要复用布局骨架时才裁切 XML
python3 skills/lark-slides/scripts/template_tool.py extract --template <template-id> --label <页型> --out /tmp/template-slice.xml
# 4. 生成待创建 XML 后先做布局风险检查
python3 skills/lark-slides/scripts/layout_lint.py --input /tmp/presentation.xml
```
执行规则:
1. `search --query` 使用用户原始描述;如用户明确风格,再额外加 `--tone light|dark|colorful``--formality formal|casual|creative`
2. 候选展示只给 2-3 个,包含模板名、适用场景、风格/色调、推荐理由;不要把完整目录贴给用户。
3. 锁定模板后,复用 `<theme>`、配色、页面流、布局骨架;所有占位文案都必须改写为用户真实内容。
4. `layout_lint.py` 有 error 时先修 XML不要提交创建只有 warning 时,检查是否是可接受的装饰/背景误报。
```text
Step 1: 需求澄清 & 读取知识
- 澄清用户需求:主题、受众、页数、风格偏好
- 如果需求明显落在已有模板场景内,主动提示用户“可以直接基于现成模板生成”,并给出 2-3 个最匹配模板候选(模板名 + 适用场景 + 风格/色调 + 简短推荐理由)
- 默认不要把完整模板目录直接贴给用户;除非用户明确要求看更多,否则只展示 2-3 个候选
- 候选优先选场景强相关模板;只有没有明显场景模板时,才用 `light_general.xml` / `dark_general.xml` 这类通用模板兜底
- 如果用户没有明确风格,根据主题推荐(见下方风格判断表)
- 如果用户要求“模板/主题/风格参考”,或主题属于常见模板场景:
· 优先运行 `python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3` 做低成本模板匹配
· 需要人类可读说明时,再读 template-catalog.md 组织候选文案
· 锁定模板后,优先运行 `template_tool.py summarize` 看 `<theme>` / 页型摘要;需要具体布局时,再用 `template_tool.py extract`
· 复用模板的 theme、配色、页面流、布局骨架不要照搬占位文案
· `references/template-index.json` 只是脚本缓存/轻量路由索引,`assets/templates/*.xml` 是机器资源;除非用户明确要求审计原始模板,否则不要直接读取
- 读取 XML Schema 参考:
· xml-schema-quick-ref.md — 元素和属性速查
· xml-format-guide.md — 详细结构与示例
· slides_demo.xml — 真实 XML 示例
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 创建
- 生成大纲前,先确认用户是否采用推荐模板;轻量任务且候选中有明显最佳匹配时,可在大纲里声明“默认基于 <template-id> 改写”并继续,但正式创建前必须给用户改选机会
- 生成结构化大纲(每页标题 + 要点 + 布局描述),交给用户确认
- 如果已选模板,大纲和页面布局要明确标注“基于哪个模板/哪些模板改写”
- 如果用户明确不要模板,直接按自定义风格继续,不要重复推动模板选择
- 先判断创建方式:
· 简单 XML可用 `slides +create --slides '[...]'` 一步创建
· 复杂 XML优先先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
· 超过 10 页:默认使用两步创建,避免单次输入过长
- 含本地图片:
· 新建带图 PPT —— 在 slide XML 里写 <img src="@./pic.png" .../>
+create 会自动上传并替换为 file_token详见 lark-slides-create.md
· 给已有 PPT 加带图新页 —— 先 `slides +media-upload --file ./pic.png --presentation $PID`
拿到 file_token再用它写进 slide XML 调 xml_presentation.slide.create
· 给已有页加图 —— 两步:① `slides +media-upload` 拿 file_token
② `slides +replace-slide --parts '[{"action":"block_insert","insertion":"<img src=\"<file_token>\" .../>"}]'`
不动其他元素,不要再整页重建(完整示例见 lark-slides-edit-workflows.md 的 block_insert 章节)
· 路径必须是 CWD 内的相对路径(如 ./pic.png 或 ./assets/x.png
绝对路径会被 CLI 拒绝,先 cd 到素材所在目录再执行
- 每页 slide 需要完整的 XML背景、文本、图形、配色
- 复杂元素table、chart需参考 XSD 原文
- 创建前必须做 XML 自检:
· 检查特殊字符是否按 XML 规则转义:文本节点和属性值里的裸 `& -> &amp;`;文本里的 `< -> &lt;`、`> -> &gt;`。例如 `Q&A -> Q&amp;A`URL 属性 `a=1&b=2 -> a=1&amp;b=2`
· 属性值里的双引号必须转义或改为外层安全包装,避免 shell 和 JSON 双重截断
· 确认所有标签闭合,且 `<slide>` 直接子元素只包含 `<style>`、`<data>`、`<note>`
· 如果内容里同时出现中文、大段文本、复杂布局、较多特殊字符,默认不要走 `--slides '[...]'`,直接改用两步创建法
· 如果 XML 已落到本地文件且可运行 Python先执行 `layout_lint.py --input <file>`;它会先检查 XML well-formed 再检查布局风险,但不等价于完整 XSD schema 校验;有 error 先修复再创建
- 如果使用模板生成页面,先复用模板骨架再填内容,不要直接复制模板中的长段占位文本
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML 做创建后验证,确认:
· 页数是否正确?
· 每页 `<data>` 是否包含预期的 `<shape>` / `<img>` / 其他元素?
· 文本内容是否完整,是否有被截断、丢失、空白区域?
· 关键布局坐标和尺寸是否合理,是否出现明显重叠?
· 配色是否统一?字号层级是否合理?
- 如果本地有 Python 3运行
`python3 skills/lark-slides/scripts/layout_lint.py --input presentation.xml`
做重叠、越界、页脚碰撞、文本高度风险检查;有 error 先修复再交付
- 如果创建过程中失败:
· 先保留并记录 `xml_presentation_id`,不要假设失败代表什么都没创建
· 先判断是否已有部分页面写入,再决定是否在现有 PPT 上修复后继续追加
· 优先排查当前失败页:先看该页 XML再检查是否存在未转义 `&`、错误引号、标签未闭合、shell 传参截断
- 局部问题 → 用 `+replace-slide` 块级修正;整页结构要改 → `slide.delete` 旧页 + `slide.create` 新页
Step 3: 按 slide_plan.json 生成 XML → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
```
### 创建后验证
创建成功不等于内容正确。创建完 PPT 后,**必须**读取全文 XML 校验结果:
```bash
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
重点检查:
- [ ] 页数是否与预期一致
- [ ] 每页 `<data>` 中是否包含所有预期元素
- [ ] 文本内容是否完整,没有被 shell 截断或转义损坏
- [ ] 白底内容区、卡片区、图文区等关键布局是否实际生成
- [ ] 坐标、宽高是否合理,是否出现堆叠或越界
发现问题时:
1. 不要假设“创建成功就代表渲染正确”
2. 先读取问题页的 XML确认是生成问题还是传参损坏
3. 删除问题页后重新添加;复杂页面优先改用两步创建法
### 最小验收清单
创建完成后,默认按下面顺序验收,不要省略:
1. 记录 `xml_presentation_id`
2. 确认返回的 `slides_added` 或实际页数是否符合预期
3. 立即执行 `xml_presentations get`
4. 检查标题、关键页面、关键文本是否存在
5. 检查是否有明显空白页、内容缺失、页序错误
6. 再决定是否向用户交付 URL 和后续编辑建议
推荐最小闭环:
```bash
# 创建
lark-cli slides +create --as user --title "Demo" --slides '[...]'
# 立即回读
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
## XML 自检与排障
在真正创建前,至少做下面 4 项检查:
- [ ] 特殊字符已转义:正文和标题里的 `&``<``>` 不能裸写;属性值里的裸 `&` 也必须写成 `&amp;`
- [ ] 属性引号安全XML 属性、shell 引号、JSON 字符串包装之间没有互相打断
- [ ] 结构合法:`<slide>` 下只放 `<style>``<data>``<note>`,文本都在 `<content>`
- [ ] 路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用
高频失败信号和处理顺序:
1. `invalid param` / 某一页创建失败
2. 先检查失败页是否含未转义 `&` / `<` / `>``Q&A -> Q&amp;A`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
3. 再检查标签闭合、属性引号、`<content>` 结构
4. 如果是 `--slides '[...]'`,怀疑 shell 截断时直接切两步创建法
5. 创建后无论成功失败,都优先记录 `xml_presentation_id` 并回读确认是否已有部分页面写入
### jq 命令模板(编辑已有 PPT 时使用)
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
@@ -276,29 +196,7 @@ lark-cli slides xml_presentation.slide create \
'{slide:{content:$content}, before_slide_id:$before}')"
```
### 风格快速判断表
> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
| 场景/主题 | 推荐风格 | 背景 | 主色 | 文字色 |
|----------|---------|------|------|-------|
| 科技/AI/产品 | 深色科技风 | 深蓝渐变 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)` | 蓝色系 `rgb(59,130,246)` | 白色 |
| 商务汇报/季度总结 | 浅色商务风 | 浅灰 `rgb(248,250,252)` | 深蓝 `rgb(30,60,114)` | 深灰 `rgb(30,41,59)` |
| 教育/培训 | 清新明亮风 | 白色 `rgb(255,255,255)` | 绿色系 `rgb(34,197,94)` | 深灰 `rgb(51,65,85)` |
| 创意/设计 | 渐变活力风 | 紫粉渐变 `linear-gradient(135deg,rgba(88,28,135,1) 0%,rgba(190,24,93,1) 100%)` | 粉紫色系 | 白色 |
| 周报/日常汇报 | 简约专业风 | 浅灰 `rgb(248,250,252)` + 顶部彩色渐变条 | 蓝色 `rgb(59,130,246)` | 深色 `rgb(15,23,42)` |
| 用户未指定 | 默认简约专业风 | 同上 | 同上 | 同上 |
### 页面布局建议
| 页面类型 | 布局要点 |
|---------|---------|
| 封面页 | 居中大标题 + 副标题 + 底部信息,背景用渐变或深色 |
| 数据概览页 | 指标卡片横排rect 背景 + 大号数字 + 小号说明),下方列表或图表 |
| 内容页 | 左侧竖线装饰 + 标题,下方分栏或列表 |
| 对比/表格页 | table 元素或并列卡片,表头深色背景白字 |
| 图表页 | chart 元素column/line/pie配合文字说明 |
| 结尾页 | 居中感谢语 + 装饰线,风格与封面呼应 |
> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。
### 大纲模板
@@ -319,12 +217,6 @@ N. 结尾页:[结尾文案]
风格:[配色方案][排版风格]
```
### 常用 Slide XML 模板
可直接复制使用的模板(封面页、内容页、数据卡片页、结尾页):[slide-templates.md](references/slide-templates.md)
---
## 核心概念
### URL 格式与 Token
@@ -338,46 +230,13 @@ N. 结尾页:[结尾文案]
### Wiki 链接特殊处理(关键!)
知识库链接(`/wiki/TOKEN`背后可能是云文档、电子表格、幻灯片等不同类型的文档。**不能直接假设 URL 中的 token 就是 `xml_presentation_id`**,必须先查询实际类型和真实 token
#### 处理流程
1. **使用 `wiki.spaces.get_node` 查询节点信息**
```bash
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
```
2. **从返回结果中提取关键信息**
- `node.obj_type`:文档类型,幻灯片对应 `slides`
- `node.obj_token`**真实的演示文稿 token**(用于后续操作)
- `node.title`:文档标题
3. **确认 `obj_type` 为 `slides` 后,使用 `obj_token` 作为 `xml_presentation_id`**
#### 查询示例
知识库链接(`/wiki/TOKEN`不能直接当 `xml_presentation_id`。直接调用原生 API 前,先查询 wiki 节点,确认 `node.obj_type == "slides"`,再用 `node.obj_token` 作为真实 presentation ID
```bash
# 查询 wiki 节点
lark-cli wiki spaces get_node --as user --params '{"token":"wikcnxxxxxxxxx"}'
lark-cli wiki spaces get_node --as user --params '{"token":"wiki_token"}'
```
返回结果示例:
```json
{
"node": {
"obj_type": "slides",
"obj_token": "xxxxxxxxxxxx",
"title": "2026 产品年度总结",
"node_type": "origin",
"space_id": "1234567890"
}
}
```
```bash
# 用 obj_token 读取幻灯片内容
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"xxxxxxxxxxxx"}'
```
Shortcut `+replace-slide``+media-upload` 会自动解析 `/wiki/` URL手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。
### 资源关系
@@ -393,39 +252,26 @@ Slides (演示文稿)
└── slide_id (页面唯一标识)
```
## Shortcuts(推荐优先使用)
## Shortcuts 与 API
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传)bot 模式自动授权 |
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+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/>`,不改变页序 |
## API Resources
```bash
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli slides <resource> <method> [flags] # 调用 API
```
> **重要**使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段格式
### xml_presentations
- `get` — 读取演示文稿全文信息XML 格式返回
### xml_presentation.slide
- `create` — 在指定 XML 演示文稿下创建页面
- `delete` — 在指定 XML 演示文稿下删除页面
- `get` — 获取指定 XML 演示文稿的单个页面 XML 内容
- `replace` — 对指定 XML 演示文稿页面进行元素级别的局部替换
原生 API 高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段。
## 核心规则
1. **先定模板/风格并出大纲再动手**:如果需求可匹配模板,先给用户 2-3 个模板候选;模板或自定义风格确定后,再生成大纲交给用户确认,避免返工
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 内
@@ -434,7 +280,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
## 权限
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
@@ -447,79 +293,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
## 常见错误速查
| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 400 | XML 格式错误 | 检查 XML 语法,确保标签闭合 |
| 400 | 请求包装错误 | 检查 `--data` 是否按 schema 传入 `xml_presentation.content` 或 `slide.content` |
| 创建成功但页面空白/内容缺失/布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建:先 `slides +create`,再用 `jq -n` 包装 `xml_presentation.slide.create` 逐页添加,并在创建后立即读取 XML 验证 |
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 404 | 幻灯片不存在 | 检查 `slide_id` 是否正确 |
| 403 | 权限不足 | 检查是否拥有对应的 scope |
| 400 | 无法删除唯一幻灯片 | 演示文稿至少保留一页幻灯片 |
| 1061002 | params error媒体上传时 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`slides 唯一可用 `parent_type` 是 `slide_file` |
| 1061004 | forbidden当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限bot 常见于 PPT 非该 bot 创建,需先授权或用 `+create --as bot` 新建 |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 `xml_presentation.slide.replace` 失败catch-all | 优先检查未转义 `&` / `<` / `>``Q&A -> Q&amp;A`,属性 URL `a=1&b=2 -> a=1&amp;b=2`;运行 `layout_lint.py --input <file>` 定位行列和上下文;再检查 replace 场景的 `block_id` / `<content/>` / 坐标 |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
## 创建前自查
逐页生成 XML 前,快速检查:
- [ ] 每页背景色/渐变是否设置?风格是否与整体一致?
- [ ] 标题用大字号28-48正文用小字号13-16层级分明
- [ ] 同类元素配色一致?(如所有指标卡片同色系、所有正文同色)
- [ ] 装饰元素(分割线、色块、竖线)颜色是否与主色协调?
- [ ] 文本框尺寸是否足够容纳内容?(宽度 × 高度)
- [ ] shape 的 `type` 是否正确?(文本框用 `text`,装饰用 `rect`
- [ ] XML 标签是否全部正确闭合?特殊字符(`&`、`<`、`>`)是否转义?
## 症状 → 修复表
| 看到的问题 | 改什么 |
|-----------|--------|
| 文字被截断/看不全 | 增大 shape 的 `width` 或 `height` |
| 元素重叠 | 调整 `topLeftX`/`topLeftY`,拉开间距 |
| 页面大面积空白 | 缩小元素间距,或增加内容填充 |
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1`/`dim2` 数据数量是否匹配 |
| 图片被裁掉一部分 | `<img>` 的 `width`/`height` 是裁剪后尺寸,比例和原图不一致时会自动裁剪;要整图显示就让 `width:height` 对齐原图比例 |
| 只想改某页的单个元素(文字/图片/形状) | 用 `+replace-slide` 块级替换,不要整页重建 |
| 想给已有页加一张图(不动原有元素) | ① `+media-upload` 拿 `file_token` ② `+replace-slide` 用 `block_insert` 插入 `<img src="<file_token>" .../>`;不要再用 "整页 create + delete" 的老流程 |
| 新插入的 `<img>` 挡住/重叠原有元素 | `slide.get` 读原页,对照已有块的 `topLeftX/Y/width/height` 挑空白位置;空间不够就在同一批 `--parts` 里先 `block_replace` 缩小/挪动现有块再 `block_insert` 图片 |
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`;用 `rgb()` 或省略停靠点会被回退为白色 |
| 渐变方向不对 | 调整 `linear-gradient` 的角度(`90deg` 水平、`180deg` 垂直、`135deg` 对角线) |
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
| API 返回 400 | 检查 XML 语法:标签闭合、属性引号、特殊字符转义 |
| API 返回 3350001 | `block_replace` 根元素缺 `id=<block_id>` 或 `<shape>` 缺 `<content/>`,详见 replace-slide 文档 |
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符**只在 `+create --slides` 中替换**;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` 写进 src |
| 上传图片报 1061002 params error | `parent_type` 必须是 `slide_file`slides 唯一接受值);不要手拼,用 `slides +media-upload` |
## 参考文档
| 文档 | 说明 |
|------|------|
| [lark-slides-create.md](references/lark-slides-create.md) | **+create Shortcut创建 PPT支持 `--slides` 一步添加页面,含 `@` 占位符自动上传图片)** |
| [lark-slides-media-upload.md](references/lark-slides-media-upload.md) | **+media-upload Shortcut上传本地图片返回 `file_token`** |
| [lark-slides-replace-slide.md](references/lark-slides-replace-slide.md) | **+replace-slide Shortcut块级替换/插入,含合法根元素速查与 3350001 排错** |
| [lark-slides-edit-workflows.md](references/lark-slides-edit-workflows.md) | 编辑已有页面的读-改-写流程与 action 决策树 |
| [template-index.json](references/template-index.json) | **脚本缓存/轻量路由索引:由 `template_tool.py search` 使用,不是默认阅读入口** |
| [template-catalog.md](references/template-catalog.md) | **按场景/色调匹配现成 PPT 模板,并定位到页型范围** |
| [`scripts/template_tool.py`](scripts/template_tool.py) | **可选 Python 辅助脚本:`search` / `summarize` / `extract`,支持 `--layout-tag` 与 `extract --with-summary`** |
| [`scripts/layout_lint.py`](scripts/layout_lint.py) | **本地预检脚本:先检查 XML well-formed再检测重叠、越界、页脚碰撞、文本高度风险不是完整 XSD schema 校验** |
| [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md) | **XML Schema 精简速查(必读)** |
| [slide-templates.md](references/slide-templates.md) | 可复制的 Slide XML 模板 |
| [xml-format-guide.md](references/xml-format-guide.md) | XML 详细结构与示例 |
| [examples.md](references/examples.md) | CLI 调用示例 |
| [slides_demo.xml](references/slides_demo.xml) | 真实 PPT 的完整 XML |
| [slides_xml_schema_definition.xml](references/slides_xml_schema_definition.xml) | **完整 Schema 定义**(唯一协议依据) |
| [lark-slides-xml-presentations-get.md](references/lark-slides-xml-presentations-get.md) | 读取 PPT 命令详情 |
| [lark-slides-xml-presentation-slide-create.md](references/lark-slides-xml-presentation-slide-create.md) | 添加幻灯片命令详情 |
| [lark-slides-xml-presentation-slide-delete.md](references/lark-slides-xml-presentation-slide-delete.md) | 删除幻灯片命令详情 |
| [lark-slides-xml-presentation-slide-get.md](references/lark-slides-xml-presentation-slide-get.md) | 读取单个幻灯片命令详情 |
| [lark-slides-xml-presentation-slide-replace.md](references/lark-slides-xml-presentation-slide-replace.md) | 原生 slide.replace API 命令详情 |
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

View File

@@ -0,0 +1,124 @@
# Asset Planning
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
## 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, or placeholder regions.
- 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.
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
## JSON Shape
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
```json
{
"asset_type": "architecture_diagram",
"purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
"suggested_query": "agent native slides runtime architecture diagram",
"fallback_if_missing": "Draw grouped boxes and arrows with XML shapes; use labels instead of an image."
}
```
For a page without a meaningful asset need, use:
```json
{
"asset_type": "none",
"purpose": "No external or simulated asset needed; the page is text-led.",
"suggested_query": "",
"fallback_if_missing": "Use typography, spacing, and simple accent shapes only."
}
```
## Supported Asset Types
- `paper_figure`: figure from a paper or technical article.
- `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, funnel, scatter, or chart-like data visual.
- `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.
- `none`: explicitly no asset needed.
Do not invent new asset types unless the user asks for a special visual format. If a need is close to these types, choose the closest one and explain the detail in `purpose`.
## Planning Guidance
Match asset type to slide role:
- `architecture-diagram` layout usually pairs with `architecture_diagram` or `flow_diagram`.
- `process-flow` layout usually pairs with `flow_diagram`, `icon`, or `infographic`.
- `comparison` layout often works with `icon`, `chart`, or `infographic`.
- `timeline` layout often works with `icon`, `chart`, or shape-based milestone markers.
- `big-number` layout often works with `chart` or `infographic`, but only if it supports the metric.
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, or `infographic`; if missing, use a large placeholder diagram or stylized panel.
`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.
`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."
- "Use a bordered placeholder panel with product area labels, not an empty image."
Weak fallbacks to avoid:
- "Use a placeholder."
- "Find another image."
- "Leave blank if unavailable."
- "Use generic decoration."
## Examples
Transformer Self-Attention page:
```json
{
"asset_type": "paper_figure",
"purpose": "Explain token-to-token attention and why each output token mixes context.",
"suggested_query": "Transformer self attention attention matrix diagram",
"fallback_if_missing": "Draw a simplified attention matrix with token labels, colored weights, and arrows from input tokens to one highlighted output token."
}
```
System architecture page:
```json
{
"asset_type": "architecture_diagram",
"purpose": "Show the runtime path from user prompt to plan, XML generation, Slides API creation, and fetch verification.",
"suggested_query": "slides generation runtime architecture planner XML API verification",
"fallback_if_missing": "Draw four grouped boxes connected left-to-right with arrows; put verification as a return arrow from Slides API to agent."
}
```
Business comparison page:
```json
{
"asset_type": "infographic",
"purpose": "Make before/after differences scannable without dense bullet lists.",
"suggested_query": "before after product workflow comparison infographic",
"fallback_if_missing": "Use two side-by-side panels with matching icon circles and three parallel rows of concise labels."
}
```
## Plan To XML Contract
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, or chart-like elements.
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

@@ -178,7 +178,7 @@ lark-cli slides xml_presentation.slide create --as user \
| 400 | XML 格式错误 | 检查 `slide.content` 是否是完整 `<slide>` 元素 |
| 400 | 请求体结构错误 | 检查是否按 `slide.content``before_slide_id` 包装 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:update``slides:presentation:write_only` scope |
| 3350001 | XML 非 well-formed 或服务端参数校验失败 | 优先检查未转义字符:文本 `Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`;创建前运行 `python3 skills/lark-slides/scripts/layout_lint.py --input <file>` 获取行列和上下文 |
| 3350001 | XML 非 well-formed 或服务端参数校验失败 | 优先检查未转义字符:文本 `Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2` |
## 注意事项
@@ -188,8 +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. **本地预检**: 创建前运行 `layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
8. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
7. **建议**: 先使用 `xml_presentations.get` 获取现有结构,再添加新页面
## 批量添加建议

View File

@@ -0,0 +1,219 @@
# Planning Layer
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构仍然需要规划层。
## Required Flow
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
6. 读取 `xml-schema-quick-ref.md``visual-planning.md``asset-planning.md`
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML`layout_type``visual_focus``text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
## Plan Path
Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.
Recommended IDs:
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
- Existing PPT rewrite: the `xml_presentation_id`.
- Ambiguous or untitled task: short task slug plus date/time.
Rules:
- Do not reuse `.lark-slides/plan/slide_plan.json` as a shared path.
- Create the directory before writing the file.
- Reuse the same plan path for XML generation and post-create verification for that deck.
## Artifact Lifecycle
`.lark-slides/` is local agent state. It supports recovery, iteration, and later edits, but it should not be treated as source code or committed by default.
Keep:
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
Clean or avoid keeping:
- Transient XML payloads after successful creation and verification. Prefer `/tmp` for throwaway XML, or delete generated XML files after success.
- Stale XML drafts that no longer match the current presentation state.
Exception:
- If creation fails or partially succeeds, keep the relevant XML/debug payloads until recovery is complete. Record `xml_presentation_id` first, then fetch current state before retrying.
## JSON Shape
```json
{
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
"visual_system": {
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
"motif": "A reusable left accent bar and consistent card/header treatments.",
"color_roles": {
"primary": "Used for the dominant structural motif and about 60-70% of visual weight.",
"secondary": "Used for grouped regions, comparison panels, or supporting categories.",
"accent": "Used only for key numbers, conclusions, or focus markers."
}
},
"typography_constraints": {
"title_max_lines": 2,
"body_max_lines_per_box": 2,
"footer_max_lines": 1,
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
},
"verification_plan": {
"check_background_consistency": true,
"check_text_fit": true,
"check_visual_focus": true,
"check_asset_rendering": true
},
"slides": [
{
"page": 1,
"title": "Proposal Title",
"key_message": "The initiative is ready for a focused pilot.",
"layout_type": "title-cover",
"visual_focus": "Large title area with one concise supporting statement.",
"asset_need": {
"asset_type": "logo",
"purpose": "Signal product or team identity on the opening page.",
"suggested_query": "product logo",
"fallback_if_missing": "Use a small text badge and abstract shape motif instead of a real logo."
},
"text_density": "low",
"speaker_intent": "Frame the decision and establish the deck's point of view."
}
]
}
```
## Required Fields
Top-level fields:
- `presentation_goal`: what the whole deck is trying to achieve.
- `audience`: target readers or listeners and their assumed background.
- `theme_style`: visual tone, palette direction, and professional style.
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
- `slides`: ordered page plans.
Each slide must include:
- `page`: 1-based page number.
- `title`: slide title.
- `key_message`: the one idea this page must land.
- `layout_type`: planned page structure.
- `visual_focus`: dominant visual object or region.
- `asset_need`: planning-only structured asset metadata; no search, download, or upload required. Follow `asset-planning.md`.
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
- `title-cover`
- `section-divider`
- `two-column`
- `image-left-text-right`
- `image-right-text-left`
- `big-number`
- `timeline`
- `comparison`
- `architecture-diagram`
- `process-flow`
- `quote-highlight`
- `conclusion`
The value must affect XML geometry, not just appear as a label. For example, `timeline` should create a horizontal or vertical sequence, `comparison` should create distinct side-by-side regions, and `big-number` should reserve dominant space for a large metric.
## Text Density Rules
- `low`: title plus 1 short statement, or 1-3 very short labels.
- `medium`: title plus 2-4 concise bullets or labeled regions.
- `high`: allowed only when the user needs detail; use tables, columns, or grouped regions instead of a long bullet list.
Do not let all pages become title + bullet slides. For decks of 4 or more pages, aim for at least 4 different `layout_type` values when the content allows it.
Text density must be realistic for the planned geometry. If a page needs long titles, bilingual labels, paper figure captions, legal disclaimers, or dense technical wording, record how the text will be shortened, split, or moved to speaker notes. Do not rely on small font sizes or tight boxes to make text fit.
## Visual System Planning
Before generating XML, define a visual system that can survive the whole deck:
- `background_strategy`: specify the default background for normal content pages, and which page roles may intentionally differ. Do not let pages drift through near-identical but inconsistent background colors.
- `motif`: choose one or two reusable structural devices, such as a side bar, header rail, numbered node, card treatment, diagram lane, or section band. The motif should appear consistently enough that pages feel related.
- `color_roles`: assign primary, secondary, and accent roles. The same color must not mean unrelated things across pages.
- `cover_content_relationship`: if the cover uses a different dark or image-led treatment, state how it connects to content pages through shared colors, motifs, or geometry.
- `closing_relationship`: if the closing page mirrors the cover, state that explicitly so it looks intentional rather than like a new theme.
These are planning constraints, not decoration notes. They must affect coordinates, background fills, shape styles, and text placement in generated XML.
## Iterative Deck State
When continuing an existing deck, update the same plan path rather than creating a new disconnected plan. Keep the plan aligned with what has actually been created.
Recommended optional fields for long-running work:
- `deck_status`: current slide count, target slide count if known, and last verified revision or timestamp.
- `created_slides`: page number, slide id when known, and the page role.
- `assets_used`: source, local path when applicable, uploaded token when known, and which page uses it.
- `open_issues`: known layout, text fit, asset, or consistency risks that still need correction.
Do not hard-code a page number just because a previous deck used that pattern. Plan by page role and evidence need, such as "method overview pages should use a figure when the source has a readable figure" instead of binding screenshots, charts, or diagrams to a fixed page index. The plan should describe decision rules, not a rigid template sequence.
## Asset Planning
`asset_need` is metadata. It can describe a desired figure, diagram, chart, icon, logo, screenshot, or fallback shape-based visual, but it must not require web search, local download, or media upload.
Use an object for one planned asset, an array for multiple real needs, or `asset_type: "none"` when no asset is useful. Each planned asset must include:
- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
- `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, arrows, labels, tables, simple charts, or placeholder panels.
For detailed rules and examples, read `asset-planning.md`.
Good examples:
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw grouped boxes and arrows with 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 line chart with shapes and value labels."}`
## XML Generation Contract
Before writing each slide XML, map the plan fields to concrete decisions:
- `key_message` determines the headline, dominant claim, or main takeaway.
- `layout_type` determines the coordinate structure and element types. Use `visual-planning.md` for concrete layout rules.
- `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.
After creating the PPT, fetch the presentation and verify:
- Page count matches the plan.
- Every page has the planned title and key message represented.
- At least several pages have visibly different XML layout structures.
- Planned `visual_focus` appears as a dominant visual region or object.
- Asset planning is proportional to the deck topic and length: technical, research, product, and analytical decks should include meaningful planned visuals where they clarify the story, and each planned asset has a visible fallback if no real asset was used.
- `text_density` is reflected in the amount of visible text.
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.

View File

@@ -17,7 +17,6 @@
5. 优先运行 `template_tool.py summarize` 查看 `<theme>` / 页型摘要;只有需要具体布局骨架时,再运行 `template_tool.py extract`
6. 从模板中提取并复用:`<theme>` 配色、页面流、shape 排列布局、装饰元素风格
7. 将用户的实际内容填充到模板的结构框架中,**不要照搬模板的占位文字**
8. 创建前运行 `layout_lint.py --input <file>`;它检查 XML well-formed 和布局风险,不等价于完整 XSD schema 校验
### 脚本快捷命令

View File

@@ -0,0 +1,63 @@
# Troubleshooting
本文件覆盖 lark-slides 的通用创建前自检、XML 排障和常见失败处理。命令专属问题优先看对应 reference例如 `+replace-slide``+media-upload``xml_presentation.slide.create`
## XML Preflight
在真正创建或替换前,至少检查:
- 特殊字符已转义:正文和标题里的 `&``<``>` 不能裸写;属性值里的裸 `&` 也必须写成 `&amp;`
- 属性引号安全XML 属性、shell 引号、JSON 字符串包装之间没有互相打断。
- 结构合法:`<slide>` 下只放 `<style>``<data>``<note>`,文本都在 `<content>` 内。
- 图片路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用;直接调用 `xml_presentation.slide.create` 必须先拿到 `file_token`
## Failure Order
遇到 `invalid param`、某一页创建失败、页面空白或布局错乱时,按顺序处理:
1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
2.`xml_presentations.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`
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
## Symptom Fixes
| 看到的问题 | 处理方式 |
|-----------|----------|
| 文字被截断 / 看不全 | 增大 shape 的 `width``height`,或减少文本量 |
| 元素重叠 | 调整 `topLeftX` / `topLeftY`,拉开间距 |
| 页面大面积空白 | 回读确认内容是否写入;若内容存在,再缩小间距或增加主体元素 |
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
| 表格列宽不合理 | 调整 `colgroup``col``width` 值 |
| 图表没有显示 | 检查 `chartPlotArea``chartData` 是否都包含,`dim1` / `dim2` 数据数量是否匹配 |
| 图片被裁掉一部分 | `<img>``width` / `height` 是裁剪后尺寸;要整图显示就让 `width:height` 对齐原图比例 |
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符只在 `+create --slides` 中替换;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload``file_token` |
| 新插入的 `<img>` 挡住原有元素 | `slide.get` 读原页,对照已有块坐标挑空白位置;空间不够就在同一批 `--parts` 里先移动/缩小现有块再插图 |
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)` |
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
## Common Errors
| 错误码 / 信号 | 含义 | 解决方案 |
|--------------|------|----------|
| 400 XML 格式错误 | XML 语法错误 | 检查标签闭合、属性引号、特殊字符转义 |
| 400 请求包装错误 | `--data` 未按 schema 包装 | 检查是否传入 `xml_presentation.content``slide.content` |
| 创建成功但页面空白 / 内容缺失 / 布局错乱 | 常见于 `--slides '[...]'` 的 shell 转义或长参数传递问题 | 改用两步创建,并在创建后立即读取 XML 验证 |
| 403 权限不足 | 身份或 scope 不匹配 | 先检查是否误用了 bot 身份,再确认 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 创建 |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符replace 场景再看 `block_id``<content/>` |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
## Command-Specific References
- 图片上传、`@path` 占位符、`file_token`:见 [lark-slides-media-upload.md](lark-slides-media-upload.md) 和 [lark-slides-create.md](lark-slides-create.md)。
- 块级替换、`block_id`、3350001 replace 细节:见 [lark-slides-replace-slide.md](lark-slides-replace-slide.md)。
- 原生 `slide.create` 包装、`before_slide_id` 和 jq 模板:见 [lark-slides-xml-presentation-slide-create.md](lark-slides-xml-presentation-slide-create.md)。

View File

@@ -0,0 +1,102 @@
# Validation Checklist
创建或大幅改写演示文稿后必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML确认目标元素已更新且未破坏周边结构。
## Required Flow
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`
2.`xml_presentations.get` 回读全文 XML。
3. 检查实际页数是否符合计划或用户要求。
4. 检查每页 `<data>` 内是否有预期主要元素。
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
6. 检查页面不是全部退化为标题加 bullet list。
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
9. 在最终回复中给出简短验证记录。
回读命令:
```bash
lark-cli slides xml_presentations get --as user \
--params '{"xml_presentation_id":"YOUR_ID"}'
```
## Automated XML Text Overlap Lint
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
```bash
python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentation.xml>
```
通过标准:
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
常见 code 的处理方向:
| code | 含义 | 处理方式 |
|------|------|----------|
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
## Page Count And Structure
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
- 技术解释页、对比页、流程页、架构页必须有匹配的结构元素,例如分组框、连线、时间轴、表格或图形化区域。
## Expected Elements
`slide_plan.json` 和用户要求逐页核对:
- 标题或主结论存在,并能对应 `key_message`
- `layout_type` 对应的主要结构已生成。
- `visual_focus` 是页面中最醒目或最大的信息区域之一。
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
如果用户指定了关键页例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
## Blank Or Broken Page Signals
把下面情况视为需要修复后再交付:
- `<data/>` 为空,或只有背景、装饰线、空 `<content/>`
- 关键文本没有出现在回读 XML 中。
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
- 页面依赖的图片区域为空,且没有 fallback visual。
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
- 大量形状坐标完全相同,导致主体内容重叠。
- 渐变背景回退成空白或白底,导致文字不可读。
## Layout And Overflow Risk
优先修复这些明显风险:
- 正文或标签框高度不足,文本很可能被截断。
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
- 重要内容越过画布边界,或贴近底部超过 `y=500`
- 高密度页使用单个长 bullet list没有分栏、表格或分组。
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
- 所有内容页都是同一套标题加 bullets 坐标。
## Verification Record
最终回复必须包含简短验证记录,建议格式:
```text
验证记录:
- 回读:已执行 xml_presentations.get实际页数 N / 预期 N。
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
```
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。

View File

@@ -0,0 +1,250 @@
# Visual Planning
新建演示文稿或大幅改写页面时,在 `slide_plan.json` 完成后、生成 XML 前读取本文件。目标是让 `layout_type``visual_focus``text_density` 变成实际页面几何,而不是只写在 plan 里。
默认画布按 `960 x 540` 规划。模板 XML 可以覆盖具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。
## Core Rules
- `layout_type` must change geometry: element positions, region sizes, alignment, and visual rhythm must differ across page types.
- `visual_focus` determines the largest or highest-contrast region. It can be an image, diagram, metric, quote, table, or shape-based placeholder.
- `text_density` caps visible text:
- `low`: title plus one short statement, or 1-3 labels.
- `medium`: title plus 2-4 concise bullets or labeled regions.
- `high`: use a table, columns, grouped labels, or annotations. Do not use one long bullet box.
- Do not create a deck where every content page is title plus bullets. For 4 or more pages, use at least 4 different layout structures when the content allows.
- Keep generous margins. Use `60-80` px outer margins on standard content pages unless a full-bleed image or cover treatment is intentional.
- Reserve vertical space for titles. A typical content title area is `y=36..90`; main content should usually start at `y>=110`.
- Avoid crowding the bottom edge. Keep non-background content above `y=500` unless it is a footer.
- Prefer fewer, larger objects over many small text boxes.
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
- Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML.
## Background And Motif Consistency
Decks can vary page backgrounds, but variation must be intentional and legible:
- Pick one default background for ordinary content pages and reuse it exactly. Avoid near-identical drift such as several slightly different off-white values unless it encodes a clear section change.
- Cover, section divider, emphasis, and conclusion pages may use a dark, image-led, or high-contrast background. They must still share the deck's primary color, motif, edge treatment, typography, or geometry.
- If a cover uses a split composition, make the split visible in the background or layout. For example, reserve a darker text region and a related but distinct visual region instead of placing all elements on one flat field.
- Reuse a small number of visual devices: side bar, card radius, node style, line weight, icon container, or footer treatment. Do not introduce a new decorative language on each page.
- Insert background and motif shapes before content elements so they do not cover text, images, or diagrams.
## Text Fit Guardrails
Use these as conservative minimums on a 960 x 540 canvas. Increase height when using bold text, Chinese text, mixed Chinese/English, or line spacing above default.
| Text use | Typical font size | Minimum height |
|----------|-------------------|----------------|
| Caption, 1 line | 10-12 | 18 |
| Caption, 2 lines | 10-12 | 30 |
| Body, 1 line | 13-16 | 24 |
| Body, 2 lines | 13-16 | 40 |
| Body, 2 lines, bold | 15-18 | 48 |
| Headline, 1 line | 24-32 | 42 |
| Title, 2 lines | 34-44 | 110 |
Additional rules:
- Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only.
- Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area.
- Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines.
- Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line.
- When a text block has more than one `<p>`, size the box for multiple lines explicitly. Do not assume the renderer will auto-expand.
- If a line contains mixed Chinese and English, budget more width than either language alone; mixed text wraps less predictably.
## Layout Types
### `title-cover`
Purpose: introduce the deck's point of view.
Geometry:
- Use one dominant title block, usually `x=70..120`, `y=150..250`, `width=700..820`.
- Add one subtitle or context line, not a bullet list.
- Optional visual focus can be a full-bleed background, large side image, accent band, or abstract shape motif.
- If the cover has a right-side diagram, screenshot, or motif cluster, use a split layout: keep the title/subtitle region within the left or central text region, and reserve a separate visual region so labels and connectors do not cross the title.
- For split covers, make the background reinforce the composition, such as a darker text side and a related visual panel. Avoid one flat field where title and diagram compete for attention.
- Keep source metadata to one short line where possible. If it wraps, shorten author lists or move details to notes.
- The main title should be controlled, normally one or two lines. Do not let it occupy both the text region and the visual region.
Text:
- `low` only unless the user explicitly asks for detail.
### `section-divider`
Purpose: reset rhythm and mark a new chapter.
Geometry:
- Use a large section number, chapter label, or single centered claim.
- Keep the page sparse. A divider is not a content page.
- Visual focus can be one oversized number, a vertical accent bar, or a full-width band.
Text:
- Title plus one phrase. No bullets.
### `two-column`
Purpose: compare two related ideas or pair explanation with evidence.
Geometry:
- Split main region into two balanced columns, for example left `x=60,width=400`, right `x=500,width=400`.
- Each column needs its own heading or visual anchor.
- Do not place one full-width bullet box under a normal title; that is not a two-column layout.
Text:
- `medium`: 2-3 short items per column.
- `high`: use grouped rows or mini table structure inside columns.
### `image-left-text-right`
Purpose: let a visual establish context, with text explaining implication.
Geometry:
- Left visual region should occupy roughly `35-45%` of slide width, often full height or tall crop.
- Right text region starts around `x=420` and should have a strong headline plus short support.
- If no real image is available, create a shape-based placeholder visual that matches `asset_need`.
- For dense screenshots, paper figures, or product captures with small labels, allocate a larger visual region when possible: often `50-65%` of slide width or at least `320` px height.
- Place screenshots in a deliberate frame or panel, and leave enough margin so axes, captions, and edge labels are not cropped by the slide boundary.
Text:
- Keep right-side text short. Avoid more than 4 bullets.
- For screenshot explanation pages, prefer 2-3 interpretation cards or callouts instead of a paragraph block.
### `image-right-text-left`
Purpose: lead with a message, then reinforce it with a visual.
Geometry:
- Left text region starts around `x=60..90`, width `400..460`.
- Right visual region occupies roughly `35-45%` of slide width.
- Align the image or placeholder with the main text block, not only with the title.
- For dense screenshots, paper figures, or product captures with small labels, increase the visual region and reduce text. A readable image is more valuable than a fully populated text column.
Text:
- Use one main claim and 2-3 supporting points.
- Keep callouts parallel and short. If a callout needs more than two lines, split it into a separate note or a new slide.
### `big-number`
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`.
- 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.
Text:
- `low` or `medium`. If detail is needed, add small annotations around the metric.
- Supporting labels must not compete with the number. Use compact labels, legends, or mini-cards rather than long explanatory bars.
### `timeline`
Purpose: show sequence, roadmap, history, or phases.
Geometry:
- Create a horizontal or vertical spine with 3-6 milestones.
- Each milestone should have a dot/card/date label connected by a line or arrow.
- Title is separate from the sequence. The sequence is the visual focus.
Text:
- Each milestone gets a short label and optional one-line explanation.
- Do not use paragraph-length milestone descriptions.
### `comparison`
Purpose: make a choice, before/after, old/new, or option tradeoff clear.
Geometry:
- Use two or three distinct panels, columns, or a table-like structure.
- Headings must be visually aligned so differences are easy to scan.
- Use color, border, icon, or label treatment to highlight the preferred option or key difference.
Text:
- Use parallel wording across columns.
- Avoid uneven long bullet lists that destroy comparability.
### `architecture-diagram`
Purpose: explain components, dependencies, or system flow.
Geometry:
- Main visual area should be a diagram, not prose.
- Use grouped boxes, lanes, arrows or lines, and short labels.
- Keep diagram labels concise. Put explanation in notes or a small side caption if needed.
Text:
- Prefer labels of 1-5 words.
- Use no more than one short explanatory text block.
- If a node label needs two lines, size the node and the text box for two lines. Do not let labels overlap connectors.
### `process-flow`
Purpose: show operational steps, workflow, or cause-effect path.
Geometry:
- Use numbered steps connected by arrows or lines.
- 3-5 steps is ideal for one slide. If there are more, group them into phases.
- The flow direction must be visually obvious.
Text:
- Each step gets a verb-led label and one short descriptor at most.
- Step labels should be parallel in length and grammar. If one step needs a long explanation, move the explanation to a side note or speaker notes.
### `quote-highlight`
Purpose: emphasize a customer voice, principle, thesis, or decision statement.
Geometry:
- Quote or claim is the dominant text object.
- Use large type, generous whitespace, and optional attribution or context badge.
- Do not combine a quote-highlight page with a normal bullet section.
Text:
- One quote or statement, plus optional attribution. No bullets.
### `conclusion`
Purpose: close with decision, recommendation, or next action.
Geometry:
- Use one dominant closing statement or call to action.
- Add up to 3 next-step cards, checklist items, or owner/date labels.
- Visual focus should be the recommendation or action, not decorative filler.
Text:
- Keep the final page easy to remember. Avoid recap overload.
- Conclusion pages may mirror the cover background, but must clearly reuse the deck's motif or color roles so the ending feels intentional.
## Screenshot And Paper Figure Pages
When a page uses a real screenshot, chart, paper figure, or product capture:
- Choose screenshot placement based on page role, not a fixed slide number. Method overview, evidence, comparison, and failure-analysis pages are common candidates; title, agenda, and conclusion pages usually are not.
- Use the real asset only when it is readable at slide size. If the figure is too dense, crop to the relevant region, create a zoomed detail, or redraw the core message with native shapes.
- A screenshot should normally be the visual focus. Do not shrink it into a decorative thumbnail while surrounding it with dense text.
- Pair the image with a small number of interpretive annotations that tell the audience what to notice.
- Always include a short source caption when using external or paper-derived visuals.
- Verify the final XML contains a supported image token or creation-time local placeholder, not an unsupported external URL.
## Plan To XML Checklist
Before creating XML for each page, answer these checks:
1. Which region is the visual focus, and is it the largest or most prominent object?
2. Does the XML geometry match the `layout_type` description above?
3. Does `text_density` limit the number of paragraphs, bullets, labels, and text boxes?
4. Would this page still be recognizable if the `layout_type` label were removed from the plan?
5. Across the deck, do multiple pages use genuinely different structures?
6. Does the background follow the planned deck strategy, and are any deviations intentional?
7. Are all text boxes large enough for their intended font size and line count?
8. If the page uses a screenshot or paper figure, is it large enough to read and accompanied by concise interpretation?
After fetching the created presentation, verify:
- Use `timeline`, `comparison`, and `architecture-diagram` only when the content calls for them; do not force irrelevant page types.
- Any planned `timeline`, `comparison`, or `architecture-diagram` page uses the matching sequence, side-by-side comparison, or component-and-connection structure.
- Pages are not crowded and do not rely on long bullet boxes.
- Main claim, supporting detail, and visual focus have clear hierarchy.
- Static XML inspection should include text-fit risk: very short text boxes containing long text, multi-paragraph boxes with insufficient height, footer text that may wrap, and labels placed directly over connectors.
- Background and motif consistency should be checked across pages, not only within one slide.

View File

@@ -8,16 +8,17 @@ import json
import re
import sys
import xml.etree.ElementTree as ET
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
class LayoutLintError(Exception):
class XmlTextOverlapLintError(Exception):
pass
def fail(message: str) -> None:
raise LayoutLintError(message)
raise XmlTextOverlapLintError(message)
def read_file(file_path: str | Path) -> str:
@@ -187,33 +188,30 @@ def is_text_element(element: dict[str, Any]) -> bool:
return element["kind"] == "shape" and element["type"] == "text"
def is_backgroundish(element: dict[str, Any], slide_area: int | float) -> bool:
if slide_area <= 0:
return False
area = element["width"] * element["height"]
if element["kind"] == "img":
return area >= slide_area * 0.45
if element["kind"] == "shape" and element["type"] != "text":
return area >= slide_area * 0.35
return False
def has_text_content(element: dict[str, Any]) -> bool:
return bool(element.get("text"))
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any], slide_area: int | float) -> bool:
if is_backgroundish(left, slide_area) or is_backgroundish(right, slide_area):
def is_decorative_text(element: dict[str, Any]) -> bool:
text = element.get("text") or ""
return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
def normalize_text_for_overlap(text: str) -> str:
return re.sub(r"\s+", "", text)
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
left_text = normalize_text_for_overlap(left.get("text") or "")
right_text = normalize_text_for_overlap(right.get("text") or "")
if not left_text or not right_text:
return False
if is_text_element(left) and is_text_element(right):
if left_text == right_text or left_text in right_text or right_text in left_text:
return True
allowed_companions = {"img", "table", "chart"}
return (
is_text_element(left) and right["kind"] in allowed_companions
) or (
is_text_element(right) and left["kind"] in allowed_companions
)
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
def estimate_text_height(element: dict[str, Any]) -> int | None:
if element["kind"] != "shape" or element["type"] != "text" or not element.get("text"):
return None
def estimate_text_line_count(element: dict[str, Any]) -> int:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
chars_per_line = max(1, int(element["width"] // max(font_size * 0.55, 1)))
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
@@ -221,43 +219,88 @@ def estimate_text_height(element: dict[str, Any]) -> int | None:
for paragraph in paragraphs:
logical_length = max(len(paragraph), 1)
line_count += max(1, -(-logical_length // chars_per_line))
return int((line_count * font_size * 1.35) + 12 + 0.999999)
return max(line_count, 1)
def lint_slide(slide_xml: str, slide_number: int, width: int, height: int) -> dict[str, Any]:
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
return None
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
char_width = max(font_size * 0.55, 1)
line_count = estimate_text_line_count(element)
visual_width = min(element["width"], max(1, len(element["text"]) * char_width))
visual_height = min(element["height"], max(1, line_count * font_size * 1.2))
return {
"x": element["x"],
"y": element["y"],
"width": visual_width,
"height": visual_height,
}
def intersection_area(left: dict[str, Any], right: dict[str, Any]) -> int | float:
width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
if width <= 0 or height <= 0:
return 0
return width * height
def is_template_text_stack(left: dict[str, Any], right: dict[str, Any]) -> bool:
if not (is_text_element(left) and is_text_element(right)):
return False
if not (has_text_content(left) and has_text_content(right)):
return True
top, bottom = sorted([left, right], key=lambda element: element["y"])
top_type = top.get("textType")
bottom_type = bottom.get("textType")
allowed_pairs = {
("title", "sub-headline"),
("title", None),
("headline", "headline"),
("headline", None),
}
if (top_type, bottom_type) not in allowed_pairs:
return False
same_column = abs(top["x"] - bottom["x"]) <= 4
vertical_offset = bottom["y"] - top["y"]
top_font_size = float(top.get("fontSize", 16))
return same_column and vertical_offset >= top_font_size * 0.75
def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
if is_text_element(left) and not has_text_content(left):
return False
if is_text_element(right) and not has_text_content(right):
return False
if is_template_text_stack(left, right):
return False
if is_text_element(left) and is_text_element(right):
if is_similar_text_overlay(left, right):
return False
left_visual = estimate_text_visual_bbox(left)
right_visual = estimate_text_visual_bbox(right)
if left_visual is None or right_visual is None:
return False
overlap_area = intersection_area(left_visual, right_visual)
if overlap_area <= 0:
return False
smaller_area = min(
left_visual["width"] * left_visual["height"],
right_visual["width"] * right_visual["height"],
)
return smaller_area > 0 and overlap_area / smaller_area >= 0.30
return False
def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
elements = extract_elements(slide_xml)
issues: list[dict[str, Any]] = []
slide_area = width * height
for element in elements:
if (
element["x"] < 0
or element["y"] < 0
or element["x"] + element["width"] > width
or element["y"] + element["height"] > height
):
issues.append(
{
"level": "error",
"code": "out_of_bounds",
"element": element["id"],
"message": f'{element["id"]} exceeds slide bounds',
}
)
estimated_height = estimate_text_height(element)
if estimated_height is not None and estimated_height > element["height"]:
issues.append(
{
"level": "warning",
"code": "text_height_risk",
"element": element["id"],
"message": f'{element["id"]} may need {estimated_height}px height but only has {element["height"]}px',
}
)
for index, left in enumerate(elements):
for right in elements[index + 1 :]:
if not intersects(left, right) or not should_flag_overlap(left, right, slide_area):
if not intersects(left, right) or not should_flag_overlap(left, right):
continue
issues.append(
{
@@ -268,31 +311,6 @@ def lint_slide(slide_xml: str, slide_number: int, width: int, height: int) -> di
}
)
footer_candidates = [
element
for element in elements
if element["kind"] == "shape"
and element["type"] == "text"
and element["y"] >= height - 80
and element["height"] <= 60
]
for footer in footer_candidates:
for element in elements:
if (
element["id"] == footer["id"]
or not intersects(footer, element)
or not should_flag_overlap(footer, element, slide_area)
):
continue
issues.append(
{
"level": "warning",
"code": "footer_collision",
"elements": [footer["id"], element["id"]],
"message": f'{footer["id"]} is being crowded by {element["id"]}',
}
)
return {"slide_number": slide_number, "element_count": len(elements), "issues": issues}
@@ -309,7 +327,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
presentation = parse_presentation(xml)
slides = [
lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
lint_slide(slide_xml, index + 1)
for index, slide_xml in enumerate(presentation["slides"])
]
error_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
@@ -323,7 +341,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
def print_usage() -> None:
print("Usage:\n python3 layout_lint.py --input <presentation.xml>", file=sys.stderr)
print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
def run_cli(argv: list[str] | None = None) -> None:
@@ -344,6 +362,6 @@ def run_cli(argv: list[str] | None = None) -> None:
if __name__ == "__main__":
try:
run_cli()
except LayoutLintError as error:
print(f"layout-lint error: {error}", file=sys.stderr)
except XmlTextOverlapLintError as error:
print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
raise SystemExit(1) from error

View File

@@ -3,13 +3,49 @@
from __future__ import annotations
import unittest
from pathlib import Path
import layout_lint
import xml_text_overlap_lint
class LayoutLintTest(unittest.TestCase):
TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "assets" / "templates"
class XmlTextOverlapLintTest(unittest.TestCase):
def assertNoXmlTextOverlapLintIssues(self, result: dict, template_path: Path) -> None:
issue_summaries = []
for slide in result.get("slides", []):
for issue in slide.get("issues", []):
issue_summaries.append(
f"slide {slide['slide_number']}: {issue['level']} {issue['code']} {issue['message']}"
)
if result.get("issues"):
for issue in result["issues"]:
issue_summaries.append(f"{issue['level']} {issue['code']} {issue['message']}")
self.assertEqual(
result["summary"]["error_count"],
0,
f"{template_path.name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
)
self.assertEqual(
result["summary"]["warning_count"],
0,
f"{template_path.name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
)
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(
template_path.read_text(encoding="utf-8"),
str(template_path),
)
self.assertNoXmlTextOverlapLintIssues(result, template_path)
def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
result = layout_lint.lint_xml(
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
@@ -29,7 +65,7 @@ class LayoutLintTest(unittest.TestCase):
self.assertIn("&amp;", issue["hint"])
def test_lint_xml_reports_unescaped_ampersand_in_attribute(self) -> None:
result = layout_lint.lint_xml(
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
@@ -46,7 +82,7 @@ class LayoutLintTest(unittest.TestCase):
self.assertIn("a=1&amp;b=2", issue["hint"])
def test_lint_xml_accepts_escaped_entities_without_suspicious_entity_warning(self) -> None:
result = layout_lint.lint_xml(
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
@@ -61,7 +97,7 @@ class LayoutLintTest(unittest.TestCase):
self.assertNotIn("issues", result)
def test_lint_xml_accepts_chinese_full_width_punctuation(self) -> None:
result = layout_lint.lint_xml(
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
@@ -74,8 +110,8 @@ class LayoutLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_single_slide_uses_default_canvas_for_layout_checks(self) -> None:
result = layout_lint.lint_xml(
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
@@ -88,11 +124,10 @@ class LayoutLintTest(unittest.TestCase):
)
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
self.assertEqual(result["summary"]["slide_count"], 1)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "out_of_bounds")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_detects_overlapping_text_boxes(self) -> None:
result = layout_lint.lint_xml(
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -111,8 +146,8 @@ class LayoutLintTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_detects_out_of_bounds_elements_and_text_height_risks(self) -> None:
result = layout_lint.lint_xml(
def test_lint_xml_does_not_check_bounds_or_text_height(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -128,13 +163,11 @@ class LayoutLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertTrue(any(issue["code"] == "out_of_bounds" for issue in result["slides"][0]["issues"]))
self.assertTrue(any(issue["code"] == "text_height_risk" for issue in result["slides"][0]["issues"]))
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
result = layout_lint.lint_xml(
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -154,6 +187,77 @@ class LayoutLintTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_does_not_check_small_out_of_bounds_elements(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img src="tok" topLeftX="-20" topLeftY="20" width="120" height="120"/>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_obviously_misplaced_large_visuals(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img src="right" topLeftX="780" topLeftY="0" width="500" height="540"/>
<img src="bottom" topLeftX="0" topLeftY="430" width="900" height="280"/>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_allows_reasonable_large_visual_bleed(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<img src="tok" topLeftX="-80" topLeftY="-20" width="1080" height="600"/>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_detects_invalid_template_text_stack_overlap(self) -> None:
cases = [
(
"subtitle-too-high",
"""
<shape type="text" topLeftX="40" topLeftY="80" width="240" height="90">
<content textType="title" fontSize="44"><p>Title</p></content>
</shape>
<shape type="text" topLeftX="40" topLeftY="90" width="240" height="80">
<content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
</shape>
""",
),
]
for name, shapes in cases:
with self.subTest(name=name):
result = xml_text_overlap_lint.lint_xml(
f"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>{shapes}</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
if __name__ == "__main__":
unittest.main()

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 7
- Coverage: 24.1%
- Covered: 8
- Coverage: 27.6%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -33,6 +33,7 @@
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence |
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands |
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_SyncDryRun locks in the request shape the +sync shortcut emits
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
// all execute. The printed envelope is then inspected for GET method,
// list-files URL, the folder_token parameter, and key phrases from Desc.
//
// Fake credentials are sufficient because --dry-run short-circuits before
// any real network call.
func TestDrive_SyncDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
desc := gjson.Get(out, "description").String()
if !strings.Contains(desc, "diff") {
t.Fatalf("description missing diff phrase, got %q\nstdout:\n%s", desc, out)
}
}
// TestDrive_SyncDryRunRejectsAbsoluteLocalDir confirms the path validator
// runs in the real binary's Validate stage and surfaces a structured error
// referencing --local-dir.
func TestDrive_SyncDryRunRejectsAbsoluteLocalDir(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "/etc",
"--folder-token", "fldcnE2E001",
"--dry-run",
},
WorkDir: t.TempDir(),
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "--local-dir") {
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_SyncDryRunRejectsMissingFolderToken confirms cobra's
// required-flag enforcement runs before our custom Validate.
func TestDrive_SyncDryRunRejectsMissingFolderToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
if result.ExitCode == 0 {
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
}
combined := result.Stdout + "\n" + result.Stderr
if !strings.Contains(combined, "folder-token") {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
// TestDrive_SyncDryRunAcceptsConflictStrategies verifies that all valid
// --on-conflict values pass Validate and produce a well-formed dry-run
// envelope.
func TestDrive_SyncDryRunAcceptsConflictStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"remote-wins", "local-wins", "keep-both", "ask"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-conflict", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
// TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies verifies that all
// valid --on-duplicate-remote values pass Validate.
func TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"fail", "newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}
// TestDrive_SyncDryRunAcceptsQuickFlag verifies that --quick passes Validate
// and produces a well-formed dry-run envelope.
func TestDrive_SyncDryRunAcceptsQuickFlag(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--quick",
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
}

View File

@@ -0,0 +1,346 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestDrive_SyncWorkflow exercises +sync against a real Drive folder, proving
// that new_remote files are pulled, new_local files are pushed, and conflicts
// are resolved according to --on-conflict.
//
// Layout (before sync):
//
// folder/ (--folder-token target)
// ├── remote-only.txt "remote" ↔ (none) → new_remote → pull
// ├── conflict.txt "remote" ↔ local: "local" → modified → resolve
// └── unchanged.txt "match" ↔ local: "match" → unchanged → skip
// local/ (--local-dir target)
// ├── local-only.txt "local" → new_local → push
// ├── conflict.txt "local" → modified → resolve
// └── unchanged.txt "match" → unchanged → skip
func TestDrive_SyncWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
writeLocal := func(rel, content string) {
t.Helper()
full := filepath.Join(workDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir parent of %s: %v", rel, err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
uploadDriveFile := func(name, content string) string {
t.Helper()
stage := "_upload_" + name
writeLocal(stage, content)
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stage,
"--folder-token", folderToken,
"--name", name,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
})
return fileToken
}
// --- Subtest: remote-wins (default) ---
t.Run("remote-wins pulls new_remote and overwrites on conflict", func(t *testing.T) {
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
tokConflict := uploadDriveFile("conflict.txt", "remote")
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
writeLocal("local/unchanged.txt", "match")
writeLocal("local/conflict.txt", "local")
writeLocal("local/local-only.txt", "local")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "remote-wins",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Summary checks.
if got := gjson.Get(out, "data.summary.pulled").Int(); got != 2 {
t.Fatalf("pulled=%d want 2 (remote-only + conflict resolved by pull)\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.summary.pushed").Int(); got < 1 {
t.Fatalf("pushed=%d want >=1 (local-only)\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.summary.failed").Int(); got != 0 {
t.Fatalf("failed=%d want 0\nstdout:\n%s", got, out)
}
// Item-level checks.
assertSyncItem(t, out, "downloaded", "pull", "remote-only.txt", tokRemoteOnly)
assertSyncItem(t, out, "downloaded", "pull", "conflict.txt", tokConflict)
assertSyncItem(t, out, "uploaded", "push", "local-only.txt", "")
// Verify local file content after sync.
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict.txt"))
if err != nil {
t.Fatalf("read conflict.txt: %v", err)
}
if string(conflictContent) != "remote" {
t.Fatalf("conflict.txt content=%q want %q", string(conflictContent), "remote")
}
require.FileExists(t, filepath.Join(workDir, "local", "remote-only.txt"))
// Convergence: +status should now show all files as unchanged.
assertSyncConverges(t, ctx, workDir, folderToken, tokUnchanged)
})
// --- Subtest: local-wins ---
t.Run("local-wins pushes new_local and overwrites remote on conflict", func(t *testing.T) {
tokConflict := uploadDriveFile("conflict-lw.txt", "remote")
_ = uploadDriveFile("remote-only-lw.txt", "remote")
writeLocal("local/conflict-lw.txt", "local-wins")
writeLocal("local/local-only-lw.txt", "local")
writeLocal("local/remote-only-lw.txt", "already-here")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "local-wins",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Conflict file should be overwritten with local version.
assertSyncItem(t, out, "overwritten", "push", "conflict-lw.txt", tokConflict)
// Verify local content is unchanged (local-wins).
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-lw.txt"))
if err != nil {
t.Fatalf("read conflict-lw.txt: %v", err)
}
if string(conflictContent) != "local-wins" {
t.Fatalf("conflict-lw.txt content=%q want %q", string(conflictContent), "local-wins")
}
})
// --- Subtest: keep-both ---
t.Run("keep-both renames local and pulls remote", func(t *testing.T) {
uploadDriveFile("conflict-kb.txt", "remote-kb")
writeLocal("local/conflict-kb.txt", "local-kb")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-conflict", "keep-both",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Should have a renamed_local item and a downloaded item.
assertSyncItem(t, out, "renamed_local", "conflict", "conflict-kb.txt", "")
assertSyncItem(t, out, "downloaded", "pull", "conflict-kb.txt", "")
// Original path now has remote content.
origContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-kb.txt"))
if err != nil {
t.Fatalf("read conflict-kb.txt: %v", err)
}
if string(origContent) != "remote-kb" {
t.Fatalf("conflict-kb.txt content=%q want %q", string(origContent), "remote-kb")
}
// A suffixed sibling should exist with the local content.
entries, err := os.ReadDir(filepath.Join(workDir, "local"))
if err != nil {
t.Fatalf("readdir local: %v", err)
}
var foundSuffixed bool
for _, e := range entries {
if strings.HasPrefix(e.Name(), "conflict-kb__lark_") && strings.HasSuffix(e.Name(), ".txt") {
foundSuffixed = true
suffixedContent, readErr := os.ReadFile(filepath.Join(workDir, "local", e.Name()))
if readErr != nil {
t.Fatalf("read suffixed file: %v", readErr)
}
if string(suffixedContent) != "local-kb" {
t.Fatalf("suffixed file content=%q want %q", string(suffixedContent), "local-kb")
}
break
}
}
if !foundSuffixed {
t.Fatalf("expected suffixed sibling conflict-kb__lark_*.txt, entries: %v", entries)
}
})
}
// TestDrive_SyncEmptyDirWorkflow proves that empty local directories are
// created on Drive during +sync, and that a subsequent +status converges.
func TestDrive_SyncEmptyDirWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-emptydir-"+suffix, "")
workDir := t.TempDir()
// Create an empty subdirectory under local.
if err := os.MkdirAll(filepath.Join(workDir, "local", "empty_sub"), 0o755); err != nil {
t.Fatalf("mkdir local/empty_sub: %v", err)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+sync",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
// Should report folder_created for the empty directory.
if !strings.Contains(out, `"action": "folder_created"`) {
t.Fatalf("expected folder_created action for empty directory, got:\n%s", out)
}
if !strings.Contains(out, `"empty_sub"`) {
t.Fatalf("expected empty_sub in items, got:\n%s", out)
}
}
// assertSyncItem checks that a sync item with the given action, direction,
// and rel_path exists in the output. If fileToken is non-empty, it also
// verifies the item carries that token.
func assertSyncItem(t *testing.T, stdout, action, direction, relPath, fileToken string) {
t.Helper()
items := gjson.Get(stdout, "data.items")
if !items.IsArray() {
t.Fatalf("data.items is not an array\nstdout:\n%s", stdout)
}
var found bool
items.ForEach(func(_, item gjson.Result) bool {
if item.Get("action").String() != action || item.Get("direction").String() != direction || item.Get("rel_path").String() != relPath {
return true
}
found = true
if fileToken != "" {
if got := item.Get("file_token").String(); got != fileToken {
t.Errorf("item %s/%s/%s file_token=%q want %q", action, direction, relPath, got, fileToken)
}
}
return false
})
if !found {
t.Fatalf("missing sync item action=%s direction=%s rel_path=%s\nstdout:\n%s", action, direction, relPath, stdout)
}
}
// assertSyncConverges runs +status after a sync and asserts that all shared
// files are unchanged (i.e. the mirror has converged).
func assertSyncConverges(t *testing.T, ctx context.Context, workDir, folderToken, unchangedToken string) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
skipDriveStatusExactIfMissingDownloadScope(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
out := result.Stdout
if got := gjson.Get(out, "data.modified.#").Int(); got != 0 {
t.Fatalf("post-sync +status modified=%d want 0\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.new_local.#").Int(); got != 0 {
t.Fatalf("post-sync +status new_local=%d want 0\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "data.new_remote.#").Int(); got != 0 {
t.Fatalf("post-sync +status new_remote=%d want 0\nstdout:\n%s", got, out)
}
if unchangedToken != "" {
assertStatusBucketEntry(t, out, "unchanged", "unchanged.txt", unchangedToken)
}
}

View File

@@ -176,6 +176,34 @@ func TestMarkdownOverwriteDryRun_RejectsEmptyFile(t *testing.T) {
assert.Contains(t, errMsg, "empty markdown content is not supported")
}
func TestMarkdownPatchDryRun_Content(t *testing.T) {
setMarkdownDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+patch",
"--file-token", "boxcnMarkdownDryRun",
"--pattern", "TODO",
"--content", "DONE",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download")
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_prepare")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_part")
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_finish")
}
func setMarkdownDryRunConfigEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

@@ -28,6 +28,7 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
suffix := clie2e.GenerateSuffix()
fileName := "lark-cli-e2e-markdown-" + suffix + ".md"
initialContent := "# Initial\n\nhello markdown workflow\n"
patchedContent := "# Initial\n\nhello patched workflow\n"
updatedContent := "# Updated\n\nnew body\n"
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
@@ -73,6 +74,34 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
fetchInitialResult.AssertStdoutStatus(t, true)
require.Equal(t, initialContent, gjson.Get(fetchInitialResult.Stdout, "data.content").String(), "stdout:\n%s", fetchInitialResult.Stdout)
patchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+patch",
"--file-token", fileToken,
"--pattern", "hello markdown workflow",
"--content", "hello patched workflow",
},
DefaultAs: "user",
})
require.NoError(t, err)
patchResult.AssertExitCode(t, 0)
patchResult.AssertStdoutStatus(t, true)
require.Equal(t, true, gjson.Get(patchResult.Stdout, "data.updated").Bool(), "stdout:\n%s", patchResult.Stdout)
require.Equal(t, int64(1), gjson.Get(patchResult.Stdout, "data.match_count").Int(), "stdout:\n%s", patchResult.Stdout)
require.NotEmpty(t, gjson.Get(patchResult.Stdout, "data.version").String(), "stdout:\n%s", patchResult.Stdout)
fetchPatchedResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+fetch",
"--file-token", fileToken,
},
DefaultAs: "user",
})
require.NoError(t, err)
fetchPatchedResult.AssertExitCode(t, 0)
fetchPatchedResult.AssertStdoutStatus(t, true)
require.Equal(t, patchedContent, gjson.Get(fetchPatchedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchPatchedResult.Stdout)
overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"markdown", "+overwrite",