mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
235
shortcuts/markdown/markdown_patch.go
Normal file
235
shortcuts/markdown/markdown_patch.go
Normal 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"))
|
||||
}
|
||||
564
shortcuts/markdown/markdown_patch_test.go
Normal file
564
shortcuts/markdown/markdown_patch_test.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
MarkdownCreate,
|
||||
MarkdownFetch,
|
||||
MarkdownPatch,
|
||||
MarkdownOverwrite,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## 参考
|
||||
|
||||
135
skills/lark-markdown/references/lark-markdown-patch.md
Normal file
135
skills/lark-markdown/references/lark-markdown-patch.md
Normal 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) — 认证和全局参数
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user