From 963a61dabc0173719419552389be11fee9d24feb Mon Sep 17 00:00:00 2001 From: wangweiming Date: Wed, 13 May 2026 12:17:55 +0800 Subject: [PATCH 1/2] feat: add markdown +patch shortcut Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224 --- README.md | 4 +- README.zh.md | 4 +- shortcuts/markdown/helpers.go | 30 ++ shortcuts/markdown/markdown_overwrite.go | 17 +- shortcuts/markdown/markdown_patch.go | 206 ++++++++++++ shortcuts/markdown/markdown_patch_test.go | 311 ++++++++++++++++++ shortcuts/markdown/markdown_test.go | 2 +- shortcuts/markdown/shortcuts.go | 1 + skills/lark-drive/SKILL.md | 2 +- .../references/lark-drive-upload.md | 2 +- skills/lark-markdown/SKILL.md | 7 +- .../references/lark-markdown-patch.md | 133 ++++++++ .../cli_e2e/markdown/markdown_dryrun_test.go | 25 ++ .../markdown/markdown_workflow_test.go | 29 ++ 14 files changed, 751 insertions(+), 22 deletions(-) create mode 100644 shortcuts/markdown/markdown_patch.go create mode 100644 shortcuts/markdown/markdown_patch_test.go create mode 100644 skills/lark-markdown/references/lark-markdown-patch.md diff --git a/README.md b/README.md index d6ff3ed70..4902dcdfa 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh.md b/README.zh.md index 2f9b7558b..b9869090b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 | diff --git a/shortcuts/markdown/helpers.go b/shortcuts/markdown/helpers.go index 1b83cf146..67315d8da 100644 --- a/shortcuts/markdown/helpers.go +++ b/shortcuts/markdown/helpers.go @@ -5,6 +5,7 @@ package markdown import ( "bytes" + "context" "errors" "fmt" "io" @@ -112,6 +113,35 @@ 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, string, 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, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil +} + func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) (int64, error) { var size int64 if spec.ContentSet { diff --git a/shortcuts/markdown/markdown_overwrite.go b/shortcuts/markdown/markdown_overwrite.go index 40ed8b0a6..648124e40 100644 --- a/shortcuts/markdown/markdown_overwrite.go +++ b/shortcuts/markdown/markdown_overwrite.go @@ -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 diff --git a/shortcuts/markdown/markdown_patch.go b/shortcuts/markdown/markdown_patch.go new file mode 100644 index 000000000..64e3b9c11 --- /dev/null +++ b/shortcuts/markdown/markdown_patch.go @@ -0,0 +1,206 @@ +// 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 + } + 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("[3] Overwrite the Markdown file when local replacement finds at least one match"). + Body(map[string]interface{}{ + "file_name": "", + "parent_type": "explorer", + "parent_node": "", + "size": "", + "file": "", + "file_token": spec.FileToken, + }). + 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 + } + + specUpload := markdownUploadSpec{ + FileToken: spec.FileToken, + } + fileName, err := resolveMarkdownOverwriteFileName(runtime, specUpload) + if err != nil { + return err + } + specUpload.FileName = fileName + + result, err := uploadMarkdownContent(runtime, specUpload, []byte(patched)) + if err != nil { + return err + } + + out["updated"] = true + out["version"] = result.Version + out["size_bytes_after"] = len(patched) + + 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", int(common.GetFloat(data, "match_count"))) + if version := common.GetString(data, "version"); version != "" { + io.WriteString(w, "version: "+version+"\n") + } + fmt.Fprintf(w, "size_bytes_before: %d\n", int(common.GetFloat(data, "size_bytes_before"))) + fmt.Fprintf(w, "size_bytes_after: %d\n", int(common.GetFloat(data, "size_bytes_after"))) +} diff --git a/shortcuts/markdown/markdown_patch_test.go b/shortcuts/markdown/markdown_patch_test.go new file mode 100644 index 000000000..d89f3f717 --- /dev/null +++ b/shortcuts/markdown/markdown_patch_test.go @@ -0,0 +1,311 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "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 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 := int(common.GetFloat(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 := int(common.GetFloat(data, "size_bytes_before")); got != len("# hello\n") { + t.Fatalf("size_bytes_before = %d, want %d", got, len("# hello\n")) + } + if got := int(common.GetFloat(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 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 := int(common.GetFloat(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 := int(common.GetFloat(data, "size_bytes_before")); got != len("# TODO\nTODO\n") { + t.Fatalf("size_bytes_before = %d, want %d", got, len("# TODO\nTODO\n")) + } + if got := int(common.GetFloat(data, "size_bytes_after")); got != len("# DONE\nDONE\n") { + t.Fatalf("size_bytes_after = %d, want %d", got, len("# DONE\nDONE\n")) + } +} + +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 := int(common.GetFloat(data, "match_count")); got != 2 { + t.Fatalf("match_count = %d, want 2", got) + } +} + +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 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 +} diff --git a/shortcuts/markdown/markdown_test.go b/shortcuts/markdown/markdown_test.go index 193ac9897..71ac32339 100644 --- a/shortcuts/markdown/markdown_test.go +++ b/shortcuts/markdown/markdown_test.go @@ -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)) diff --git a/shortcuts/markdown/shortcuts.go b/shortcuts/markdown/shortcuts.go index 5bc2d02ad..95ef9e369 100644 --- a/shortcuts/markdown/shortcuts.go +++ b/shortcuts/markdown/shortcuts.go @@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ MarkdownCreate, MarkdownFetch, + MarkdownPatch, MarkdownOverwrite, } } diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index fe55ff61c..9ef593f62 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -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`。 diff --git a/skills/lark-drive/references/lark-drive-upload.md b/skills/lark-drive/references/lark-drive-upload.md index be94fb417..f504596d6 100644 --- a/skills/lark-drive/references/lark-drive-upload.md +++ b/skills/lark-drive/references/lark-drive-upload.md @@ -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)。 ## 命令 diff --git a/skills/lark-markdown/SKILL.md b/skills/lark-markdown/SKILL.md index f91115747..86117d4c4 100644 --- a/skills/lark-markdown/SKILL.md +++ b/skills/lark-markdown/SKILL.md @@ -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,9 @@ metadata: - 直接传字符串 - `@file` 从本地文件读取内容 - `-` 从 stdin 读取内容 +- `markdown +patch` 的内部语义是:**先完整下载 Markdown,再本地替换,再整文件覆盖上传** +- `markdown +patch` 不是服务端原子 patch;它是 CLI 侧编排出来的局部更新能力 +- `markdown +patch` 当前只支持**单组** `--pattern` / `--content` - `--file` 只接受本地 `.md` 文件路径 ## Shortcuts(推荐优先使用) @@ -38,6 +42,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown + [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 | ## 参考 diff --git a/skills/lark-markdown/references/lark-markdown-patch.md b/skills/lark-markdown/references/lark-markdown-patch.md new file mode 100644 index 000000000..5c0fc82b7 --- /dev/null +++ b/skills/lark-markdown/references/lark-markdown-patch.md @@ -0,0 +1,133 @@ +# 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 全量替换 +- `0` 命中时命令仍然成功返回,但不会上传新版本 + +## 实现边界 + +- 该命令的内部语义是:**download -> local replace -> overwrite upload** +- 它不是服务端原子 patch +- 它不会返回详细匹配位置,只返回命中数量 + +## 返回值 + +命中并写入新版本: + +```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) — 认证和全局参数 diff --git a/tests/cli_e2e/markdown/markdown_dryrun_test.go b/tests/cli_e2e/markdown/markdown_dryrun_test.go index b957de34e..71c2bf2f1 100644 --- a/tests/cli_e2e/markdown/markdown_dryrun_test.go +++ b/tests/cli_e2e/markdown/markdown_dryrun_test.go @@ -176,6 +176,31 @@ 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") +} + func setMarkdownDryRunConfigEnv(t *testing.T) { t.Helper() t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) diff --git a/tests/cli_e2e/markdown/markdown_workflow_test.go b/tests/cli_e2e/markdown/markdown_workflow_test.go index 149a85f0e..ba0addb25 100644 --- a/tests/cli_e2e/markdown/markdown_workflow_test.go +++ b/tests/cli_e2e/markdown/markdown_workflow_test.go @@ -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", From 5bdc5204f828dc32c7e9e952362bdcfdfced2eea Mon Sep 17 00:00:00 2001 From: wangweiming Date: Mon, 18 May 2026 17:13:35 +0800 Subject: [PATCH 2/2] fix: align markdown patch validation and dry-run Change-Id: I98079901e980b74998938afc4917b91a79689948 --- shortcuts/common/extract.go | 20 ++ shortcuts/common/extract_test.go | 26 ++ shortcuts/markdown/helpers.go | 17 +- shortcuts/markdown/markdown_patch.go | 43 ++- shortcuts/markdown/markdown_patch_test.go | 267 +++++++++++++++++- skills/lark-markdown/SKILL.md | 1 + .../references/lark-markdown-patch.md | 8 +- .../cli_e2e/markdown/markdown_dryrun_test.go | 3 + 8 files changed, 363 insertions(+), 22 deletions(-) diff --git a/shortcuts/common/extract.go b/shortcuts/common/extract.go index 382977eda..cea127c1e 100644 --- a/shortcuts/common/extract.go +++ b/shortcuts/common/extract.go @@ -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 { diff --git a/shortcuts/common/extract_test.go b/shortcuts/common/extract_test.go index 373bfc906..5b57cf153 100644 --- a/shortcuts/common/extract_test.go +++ b/shortcuts/common/extract_test.go @@ -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, diff --git a/shortcuts/markdown/helpers.go b/shortcuts/markdown/helpers.go index 67315d8da..0ba09ea33 100644 --- a/shortcuts/markdown/helpers.go +++ b/shortcuts/markdown/helpers.go @@ -131,15 +131,22 @@ func resolveMarkdownOverwriteFileName(runtime *common.RuntimeContext, spec markd return fileName, nil } -func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, string, error) { +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 nil, output.ErrNetwork("download failed: %s", err) } - return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil + 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) { @@ -157,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 } diff --git a/shortcuts/markdown/markdown_patch.go b/shortcuts/markdown/markdown_patch.go index 64e3b9c11..95cbe7aa7 100644 --- a/shortcuts/markdown/markdown_patch.go +++ b/shortcuts/markdown/markdown_patch.go @@ -60,6 +60,7 @@ var MarkdownPatch = common.Shortcut{ 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"). @@ -76,7 +77,7 @@ var MarkdownPatch = common.Shortcut{ }, }). POST("/open-apis/drive/v1/files/upload_all"). - Desc("[3] Overwrite the Markdown file when local replacement finds at least one match"). + 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": "", "parent_type": "explorer", @@ -85,12 +86,35 @@ var MarkdownPatch = common.Shortcut{ "file": "", "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": "", + "parent_type": "explorer", + "parent_node": "", + "size": "", + "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": "", + "seq": "", + "size": "", + "file": "", + }). + 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": "", + "block_num": "", + }). Set("mode", mode) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := newMarkdownPatchSpec(runtime) - resp, _, err := openMarkdownDownload(ctx, runtime, spec.FileToken) + resp, err := openMarkdownDownload(ctx, runtime, spec.FileToken) if err != nil { return err } @@ -126,6 +150,11 @@ var MarkdownPatch = common.Shortcut{ return nil } + patchedPayload := []byte(patched) + if err := validateNonEmptyMarkdownSize(int64(len(patchedPayload))); err != nil { + return err + } + specUpload := markdownUploadSpec{ FileToken: spec.FileToken, } @@ -135,14 +164,14 @@ var MarkdownPatch = common.Shortcut{ } specUpload.FileName = fileName - result, err := uploadMarkdownContent(runtime, specUpload, []byte(patched)) + result, err := uploadMarkdownContent(runtime, specUpload, patchedPayload) if err != nil { return err } out["updated"] = true out["version"] = result.Version - out["size_bytes_after"] = len(patched) + out["size_bytes_after"] = len(patchedPayload) runtime.OutFormat(out, nil, func(w io.Writer) { prettyPrintMarkdownPatch(w, out) @@ -197,10 +226,10 @@ func prettyPrintMarkdownPatch(w io.Writer, data map[string]interface{}) { io.WriteString(w, "updated: false\n") } io.WriteString(w, "mode: "+common.GetString(data, "mode")+"\n") - fmt.Fprintf(w, "match_count: %d\n", int(common.GetFloat(data, "match_count"))) + 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", int(common.GetFloat(data, "size_bytes_before"))) - fmt.Fprintf(w, "size_bytes_after: %d\n", int(common.GetFloat(data, "size_bytes_after"))) + 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")) } diff --git a/shortcuts/markdown/markdown_patch_test.go b/shortcuts/markdown/markdown_patch_test.go index d89f3f717..37ea199a0 100644 --- a/shortcuts/markdown/markdown_patch_test.go +++ b/shortcuts/markdown/markdown_patch_test.go @@ -5,10 +5,13 @@ 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" @@ -73,6 +76,70 @@ func TestMarkdownPatchValidation(t *testing.T) { } } +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 != "" { + 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 != "" { + 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{ @@ -99,16 +166,16 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) { if got := common.GetString(data, "mode"); got != markdownPatchModeLiteral { t.Fatalf("mode = %q, want %q", got, markdownPatchModeLiteral) } - if got := int(common.GetFloat(data, "match_count")); got != 0 { + 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 := int(common.GetFloat(data, "size_bytes_before")); got != len("# hello\n") { + 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 := int(common.GetFloat(data, "size_bytes_after")); 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"`) { @@ -116,6 +183,43 @@ func TestMarkdownPatchReturnsSuccessWhenNothingMatches(t *testing.T) { } } +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{ @@ -177,20 +281,81 @@ func TestMarkdownPatchLiteralOverwrite(t *testing.T) { if !common.GetBool(data, "updated") { t.Fatalf("updated = false, want true") } - if got := int(common.GetFloat(data, "match_count")); got != 2 { + 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 := int(common.GetFloat(data, "size_bytes_before")); got != len("# TODO\nTODO\n") { + 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 := int(common.GetFloat(data, "size_bytes_after")); got != len("# DONE\nDONE\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{ @@ -244,11 +409,22 @@ func TestMarkdownPatchRegexOverwrite(t *testing.T) { if got := common.GetString(data, "mode"); got != markdownPatchModeRegex { t.Fatalf("mode = %q, want %q", got, markdownPatchModeRegex) } - if got := int(common.GetFloat(data, "match_count")); got != 2 { + 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{ @@ -298,6 +474,26 @@ func TestMarkdownPatchAllowsEmptyReplacement(t *testing.T) { } } +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() @@ -309,3 +505,60 @@ func decodeMarkdownEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]inter } 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 +} diff --git a/skills/lark-markdown/SKILL.md b/skills/lark-markdown/SKILL.md index 86117d4c4..e8d57f7aa 100644 --- a/skills/lark-markdown/SKILL.md +++ b/skills/lark-markdown/SKILL.md @@ -32,6 +32,7 @@ metadata: - `markdown +patch` 的内部语义是:**先完整下载 Markdown,再本地替换,再整文件覆盖上传** - `markdown +patch` 不是服务端原子 patch;它是 CLI 侧编排出来的局部更新能力 - `markdown +patch` 当前只支持**单组** `--pattern` / `--content` +- `markdown +patch` 替换后的最终内容**不能为空**;如果替换后整篇 Markdown 变成空字符串,CLI 会直接报错,不会上传空文件 - `--file` 只接受本地 `.md` 文件路径 ## Shortcuts(推荐优先使用) diff --git a/skills/lark-markdown/references/lark-markdown-patch.md b/skills/lark-markdown/references/lark-markdown-patch.md index 5c0fc82b7..5a618cc5d 100644 --- a/skills/lark-markdown/references/lark-markdown-patch.md +++ b/skills/lark-markdown/references/lark-markdown-patch.md @@ -54,7 +54,7 @@ lark-cli markdown +patch \ | `--file-token` | 是 | 目标 Markdown 文件 token | | `--pattern` | 是 | 要匹配的文本;默认按字面量处理;支持直接传字符串、`@file`、`-`(stdin) | | `--content` | 是 | 替换后的内容;支持直接传字符串、`@file`、`-`(stdin);允许空字符串 `''`,表示删除匹配内容 | -| `--regex` | 否 | 将 `--pattern` 按 Go RE2 正则解释;`--content` 支持 `$1` 这类分组替换 | +| `--regex` | 否 | 将 `--pattern` 按 Go RE2 正则解释;`--content` 支持 `$1` 这类分组替换;如果需要字面 `$`,请写成 `$$` | ## 关键约束 @@ -62,14 +62,16 @@ lark-cli markdown +patch \ - `--pattern` 必须显式传入且不能为空字符串 - `--content` 必须显式传入,但允许为空字符串 - 未加 `--regex` 时,行为等价于对整份 Markdown 文本执行 `strings.ReplaceAll` -- 加了 `--regex` 时,行为等价于对整份 Markdown 文本执行 RE2 全量替换 +- 加了 `--regex` 时,行为等价于对整份 Markdown 文本执行 RE2 全量替换;`--content` 里的 `$1`、`${name}` 会按 Go regexp replacement template 解释,字面 `$` 请写成 `$$` +- 替换后的最终 Markdown 不能为空;如果 patch 结果是空字符串,CLI 会直接报错,不会上传空文件 - `0` 命中时命令仍然成功返回,但不会上传新版本 ## 实现边界 - 该命令的内部语义是:**download -> local replace -> overwrite upload** -- 它不是服务端原子 patch +- 它不是服务端原子 patch;如果有人在你下载后、上传前更新了同一文件,本次 patch 仍可能覆盖那次中间修改 - 它不会返回详细匹配位置,只返回命中数量 +- `--dry-run` 会同时展示两种可能的上传路径:`upload_all`(小文件)和 `upload_prepare/upload_part/upload_finish`(大文件分片上传) ## 返回值 diff --git a/tests/cli_e2e/markdown/markdown_dryrun_test.go b/tests/cli_e2e/markdown/markdown_dryrun_test.go index 71c2bf2f1..9ba6933be 100644 --- a/tests/cli_e2e/markdown/markdown_dryrun_test.go +++ b/tests/cli_e2e/markdown/markdown_dryrun_test.go @@ -199,6 +199,9 @@ func TestMarkdownPatchDryRun_Content(t *testing.T) { 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) {