Compare commits

...

10 Commits

Author SHA1 Message Date
sunpeiyang.996
1a79483ac5 fix(docs): preserve local image dimensions 2026-07-30 15:50:48 +08:00
sunpeiyang.996
cd8db34f83 fix(docs): remove failed file placeholder wrappers 2026-07-30 15:50:47 +08:00
sunpeiyang.996
e1c5ade76e test(docs): accept normalized image captions 2026-07-30 15:50:47 +08:00
sunpeiyang.996
26d8f16fa0 test(docs): replay exported markdown directly 2026-07-30 15:50:47 +08:00
sunpeiyang.996
48936606c7 test(docs): harden local resource live e2e 2026-07-30 15:50:47 +08:00
sunpeiyang.996
43825e15ed test(docs): cover local resource regressions 2026-07-30 15:50:46 +08:00
sunpeiyang.996
0929b3b8ff test(docs): gate PPE-only local resource workflow 2026-07-30 15:50:46 +08:00
sunpeiyang.996
eb4bae573d test(docs): read enveloped dry-run output 2026-07-30 15:50:46 +08:00
sunpeiyang.996
d08af40faf fix(docs): satisfy current main quality gates 2026-07-30 15:50:45 +08:00
sunpeiyang.996
c015d15d60 feat(docs): upload local image and file resources 2026-07-30 15:50:45 +08:00
19 changed files with 3866 additions and 59 deletions

View File

@@ -627,7 +627,7 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
authErr.Hint = "existing hint"
applyNeedAuthorizationHint(f, authErr)
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
want := "existing hint\ncurrent command requires scope(s): docx:document:create, docs:document.media:upload, docx:document:write_only, docx:document:readonly"
if authErr.Hint != want {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}

View File

@@ -5,9 +5,11 @@ package common
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -50,6 +52,9 @@ type DriveMediaMultipartUploadConfig struct {
ParentType string
ParentNode string
Extra string
// MinRequestInterval is an optional caller-owned pacing interval between
// prepare, part, and finish requests for APIs that disallow concurrency.
MinRequestInterval time.Duration
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
Reader io.Reader
}
@@ -128,14 +133,34 @@ func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMulti
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
if err := waitDriveMediaMultipartRequest(runtime.Ctx(), cfg.MinRequestInterval); err != nil {
return "", err
}
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
}
if err := waitDriveMediaMultipartRequest(runtime.Ctx(), cfg.MinRequestInterval); err != nil {
return "", err
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
}
func waitDriveMediaMultipartRequest(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
// message so callers see which upload step failed. Non-typed errors are
// returned unchanged.
@@ -206,6 +231,11 @@ func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMedia
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
if seq > 0 {
if err := waitDriveMediaMultipartRequest(runtime.Ctx(), cfg.MinRequestInterval); err != nil {
return err
}
}
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining

View File

@@ -531,7 +531,7 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
case "docx":
return docRef.Token, nil
case "doc":
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
data, err := runtime.CallAPITyped(
@@ -551,13 +551,13 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if objType != "docx" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but this document operation only supports docx documents", objType).WithParam("--doc")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken))
return objToken, nil
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "this document operation only supports docx documents").WithParam("--doc")
}
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"path/filepath"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
@@ -138,6 +139,9 @@ type UploadDocMediaFileConfig struct {
ParentType string
ParentNode string
DocID string
// MinRequestInterval serializes the prepare/part/finish requests of a
// multipart upload. Zero preserves the generic uploader's existing behavior.
MinRequestInterval time.Duration
}
func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileConfig) (string, error) {
@@ -164,13 +168,14 @@ func uploadDocMediaFile(runtime *common.RuntimeContext, cfg UploadDocMediaFileCo
})
}
return common.UploadDriveMediaMultipartTyped(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: cfg.FilePath,
Reader: cfg.Reader,
FileName: cfg.FileName,
FileSize: cfg.FileSize,
ParentType: cfg.ParentType,
ParentNode: cfg.ParentNode,
Extra: extra,
FilePath: cfg.FilePath,
Reader: cfg.Reader,
FileName: cfg.FileName,
FileSize: cfg.FileSize,
ParentType: cfg.ParentType,
ParentNode: cfg.ParentNode,
Extra: extra,
MinRequestInterval: cfg.MinRequestInterval,
})
}

View File

@@ -14,14 +14,21 @@ func v1CreateFlags() []common.Flag {
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
}
var docsCreateLocalResourceScopes = []string{
"docs:document.media:upload",
"docx:document:write_only",
"docx:document:readonly",
}
var DocsCreate = common.Shortcut{
Service: "docs",
Command: "+create",
Description: "Create a Lark document",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
PostMount: installDocsShortcutHelp("+create"),
Service: "docs",
Command: "+create",
Description: "Create a Lark document",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
ConditionalScopes: docsCreateLocalResourceScopes,
PostMount: installDocsShortcutHelp("+create"),
Flags: concatFlags(
[]common.Flag{
docsAPIVersionCompatFlag(),

View File

@@ -46,14 +46,19 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("content") != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return err
}
if len(input.LocalResources) > 0 {
return runtime.EnsureScopes(docsCreateLocalResourceScopes)
}
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
body, resources, err := buildCreateBodyWithPreparedInput(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
@@ -61,14 +66,15 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
if runtime.IsBot() {
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
}
return common.NewDryRunAPI().
dry := common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").
Desc(desc).
Body(body)
return appendLocalDocResourcesDryRun(dry, "<created_document_id>", resources)
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
body, resources, err := buildCreateBodyWithPreparedInput(runtime)
if err != nil {
return err
}
@@ -80,6 +86,12 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
augmentDocsCreatePermission(runtime, data)
fallbackDocsCreateURLV2(runtime, data)
if len(resources) > 0 {
doc, _ := data["document"].(map[string]interface{})
if err := finalizeLocalDocResources(runtime, strings.TrimSpace(common.GetString(doc, "document_id")), data, resources); err != nil {
return err
}
}
runtime.OutRaw(data, nil)
return nil
}

View File

@@ -14,14 +14,31 @@ func v1UpdateFlags() []common.Flag {
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
}
var docsUpdateLocalResourceScopes = []string{
"docs:document.media:upload",
}
var docsUpdateWikiLocalResourceScopes = []string{
"docs:document.media:upload",
"wiki:node:retrieve",
}
func docsUpdateLocalResourceScopesFor(ref documentRef) []string {
if ref.Kind == "wiki" {
return docsUpdateWikiLocalResourceScopes
}
return docsUpdateLocalResourceScopes
}
var DocsUpdate = common.Shortcut{
Service: "docs",
Command: "+update",
Description: "Update a Lark document",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+update"),
Service: "docs",
Command: "+update",
Description: "Update a Lark document",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
ConditionalScopes: docsUpdateWikiLocalResourceScopes,
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+update"),
Flags: concatFlags(
[]common.Flag{
docsAPIVersionCompatFlag(),

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -50,7 +51,8 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
docRef, err := parseDocumentRef(runtime.Str("doc"))
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc")
}
cmd := runtime.Str("command")
@@ -118,8 +120,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
}
}
if content != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return err
}
if len(input.LocalResources) > 0 {
if err := validateLocalDocResourceUpdateCommand(cmd, input.LocalResources); err != nil {
return err
}
return runtime.EnsureScopes(docsUpdateLocalResourceScopesFor(docRef))
}
}
return nil
}
@@ -127,32 +137,50 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
body, resources, err := buildUpdateBodyWithPreparedInput(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
return common.NewDryRunAPI().
PUT(apiPath).
documentID := ref.Token
dry := common.NewDryRunAPI()
if len(resources) > 0 && ref.Kind == "wiki" {
documentID = "<resolved_docx_token>"
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc("Resolve wiki node to its docx document before writing local resources").
Params(map[string]interface{}{"token": ref.Token})
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID))
dry.PUT(apiPath).
Desc("OpenAPI: update document").
Body(body).
Set("document_id", ref.Token)
Set("document_id", documentID)
return appendLocalDocResourcesDryRun(dry, documentID, resources)
}
func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocumentRef(runtime.Str("doc"))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
body, resources, err := buildUpdateBodyWithPreparedInput(runtime)
if err != nil {
return err
}
documentID := ref.Token
if len(resources) > 0 && ref.Kind == "wiki" {
documentID, err = resolveDocxDocumentID(runtime, runtime.Str("doc"))
if err != nil {
return err
}
}
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", validate.EncodePathSegment(documentID))
data, err := doDocAPI(runtime, "PUT", apiPath, body)
if err != nil {
return err
}
if err := finalizeLocalDocResources(runtime, documentID, data, resources); err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
}

View File

@@ -76,7 +76,14 @@ func extractDocumentFragment(raw string) string {
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id
// surfaces for support escalations even when the body omits it.
func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) {
return runtime.CallAPITyped(method, apiPath, nil, body)
data, err := runtime.CallAPITyped(method, apiPath, nil, body)
if err != nil {
return data, err
}
if data == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "document API returned an empty data object")
}
return data, nil
}
func docsSceneFromContext(ctx context.Context) string {

View File

@@ -49,8 +49,9 @@ type html5BlockReferenceEntry struct {
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
type docsV2WriteInput struct {
Content string
ReferenceMap map[string]interface{}
Content string
ReferenceMap map[string]interface{}
LocalResources []localDocResource
}
type html5BlockAttr struct {
@@ -68,27 +69,35 @@ type whiteboardStartTag struct {
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
func buildCreateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
return body, nil
return body, nil, nil
}
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
return nil, nil, err
}
body["content"] = buildCreateContentWithBody(runtime, input.Content)
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
return body, input.LocalResources, nil
}
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body, _, err := buildUpdateBodyWithPreparedInput(runtime)
return body, err
}
func buildUpdateBodyWithPreparedInput(runtime *common.RuntimeContext) (map[string]interface{}, []localDocResource, error) {
body := buildUpdateBody(runtime)
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
return nil, nil, err
}
if err := validateLocalDocResourceUpdateCommand(runtime.Str("command"), input.LocalResources); err != nil {
return nil, nil, err
}
if input.Content != "" {
body["content"] = input.Content
@@ -96,7 +105,7 @@ func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[s
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
return body, input.LocalResources, nil
}
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
@@ -125,7 +134,11 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
content, localResources, err := prepareLocalDocResources(runtime, runtime.Str("doc-format"), input.Content)
if err != nil {
return docsV2WriteInput{}, err
}
content, err = prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), content)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -138,8 +151,9 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
}
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
return docsV2WriteInput{
Content: content,
ReferenceMap: refMap,
Content: content,
ReferenceMap: refMap,
LocalResources: localResources,
}, nil
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,9 @@
# 创建 XML 文档(默认格式,推荐)
lark-cli docs +create --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
# 正文中直接插入当前目录内的本地图片和附件
lark-cli docs +create --content '<title>周报</title><img path="@images/chart.png"/><source path="@files/report.pdf"/>'
# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title正文标题按内容自然组织
lark-cli docs +create --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
```
@@ -41,6 +44,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
```
- **`document.new_blocks`**:本次操作新增的 block 列表(如画板)。`block_id` 可用于 `docs +update``--block-id` 做精确编辑;`block_token` 是资源块(如画板)的 token可交给 `lark-whiteboard` 等 skill 继续操作
- 正文包含 `<img path="@relative">``<source path="@relative">` 或 Markdown `![alt](@relative)`CLI 会在创建文档后自动上传本地资源并回填 token路径只允许位于当前工作目录内。全部成功时输出结构不变`new_blocks[].block_token` 已替换为真实媒体 token部分失败时返回 `ok:false` 和逐项 `summary/items`,但不会回滚正文或已成功资源。
> \[!IMPORTANT]
> 如果文档是**以应用身份bot创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。

View File

@@ -66,6 +66,19 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
- URL 支持 `http://``https://` 协议
- 对应的 XML 格式为:`<img href="https://example.com/photo.png"/>`
也支持直接引用当前工作目录内的本地图片:
```markdown
![alt text](@images/photo.png)
![带空格的路径](<@images/product shot.png>)
```
- 路径必须以 `@` 开头,并且是当前工作目录内的相对路径;绝对路径、目录穿越、逃逸到目录外的符号链接、目录和空文件都会在写文档前被拒绝。
- `![alt]` 的描述会作为图片 caption 落盘,后续导出 Markdown 时仍会恢复为图片 alt。
- 代码围栏、行内代码、四空格/Tab 缩进代码、HTML/XML 注释和 CDATA 中的图片或附件语法不会被处理。
- 本地图片暂不支持引用式写法(如 `![alt][ref]` + `[ref]: @image.png`);请改用上面的行内写法。
- 本地附件没有 Markdown 原生简写;使用 `<source path="@files/report.pdf"/>`
-`docs +update` 中,本地图片和附件只允许配合 `append``block_insert_after`,其他写入指令会在 API 调用前被拒绝。
- CLI 不会把本地路径发送给文档服务。写入成功后返回的 `document.new_blocks[].block_token` 是真实媒体 token如果部分资源失败正文和已成功资源会保留失败占位会尽力清理并通过结构化 `summary/items` 报告。
## Markdown 不支持的 Block 类型
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。

View File

@@ -56,6 +56,8 @@
### str_replace — 全文文本替换
> 本地图片和附件只允许用于 `append` 或 `block_insert_after`。`str_replace` 不会创建资源 block而 `block_replace` / `overwrite` 一旦在后续上传绑定失败会先破坏旧内容,因此 CLI 会在写文档前拒绝这些组合。
> **匹配范围:**
> - **XML 模式(默认)**`--pattern` 只支持**行内匹配**,不能跨 block / 跨段落匹配。涉及整段或多 block 的改动,请改用 `block_replace`。
> - **Markdown 模式**`--doc-format markdown``--pattern` 同时支持**行内和跨行匹配**,可以用多行字符串匹配并替换一整段内容。
@@ -144,6 +146,10 @@ lark-cli docs +update --doc "<doc_id>" --command overwrite \
```bash
lark-cli docs +update --doc "<doc_id>" --command append \
--content '<h2>新增章节</h2><p>追加的内容</p>'
# 追加当前目录内的本地图片和附件wiki URL 会先解析为实际 docx token
lark-cli docs +update --doc "<doc_id或wiki_url>" --command append \
--content '<img path="@images/chart.png"/><source path="@files/report.pdf"/>'
```
> 等价于 `block_insert_after --block-id -1`,无需先获取 block ID。
@@ -197,6 +203,8 @@ lark-cli docs +update --doc "<doc_id>" --command block_move_after \
| `warnings` | 警告信息列表 |
| `document.new_blocks` | 本次操作新增的 block 列表(如画板)。`block_id` 可用于后续精确编辑;`block_token` 是资源块 token如画板可交给 `lark-whiteboard` 等 skill 继续操作 |
`append` / `block_insert_after` 可写入本地图片或附件。CLI 会使用本次 `new_blocks` 中的占位标记严格关联 block完成上传和 token 回填wiki URL 会先通过 `wiki:node:retrieve` 解析为实际 docx token再执行写入、上传和绑定。路径不会发送到服务端全部成功时仍使用上面的既有输出结构部分失败时增加结构化 `summary/items`,保留正文和已经成功的资源,并清理确认仍为空的失败占位。
## 典型工作流
### 精确 block 级更新
@@ -241,7 +249,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
- **XML 模式(默认)**`--pattern` 只支持**行内**匹配,不支持跨行 / 跨 block。段落、整块或容器级列表、表格、分栏、引用块等改动请改用 `block_replace` 指定 block_id 重建。
- **Markdown 模式**`--doc-format markdown``--pattern` 同时支持**行内和跨行**匹配,还支持 `前缀...后缀` 省略号语法(用 `...` 串联首尾片段匹配一大段内容),可以一次替换多行文本;但仍建议优先按最小片段匹配,跨 block 容器级重写仍优先用 `block_replace`,避免副作用。
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
- **str_replace 的 replacement 支持行内富文本**:可以用 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本,但不支持需要新建 block 的本地图片或附件
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:

View File

@@ -26,8 +26,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id``<cite type="user" user-id="userID"></cite>` |
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
| `<source>` | 文件附件(可独立成块或内联) | `<source name="报告.pdf"/>` |
| `<img>` | 图片(可独立成块或内联) | 网络图片:`<img href="https://..."/>`;当前目录内本地图片:`<img path="@images/a.png"/>` |
| `<source>` | 文件附件(可独立成块或内联) | 当前目录内本地文件:`<source path="@files/report.pdf" name="报告.pdf"/>` |
| `<a type="url-preview">` | 预览卡片 | `<a type="url-preview" href="...">标题</a>` |
| `<button>` | 操作按钮 | `background-color``src`,必须包含 `action=OpenLink\|DuplicatePage\|FollowPage` |
| `<time>` | 提醒 | 必包含 `expire-time``notify-time`(毫秒时间戳)、`should-notify=true\|false` |
@@ -41,6 +41,9 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
- `<img>``<img href="https://..."/>` 上传网络图片
- `<img path="@relative/path.png" caption="说明"/>` — 在 `docs +create`,或 `docs +update --command append/block_insert_after` 中直接插入本地图片;`path` 必须是当前工作目录内的相对路径,不能与 `src` / `href` / `token` / `img_key` 同时使用。CLI 会先创建占位 block再上传并回填真实 token同一文件出现多次会分别上传、分别挂载。兼容旧写法 `alt="说明"`:未显式提供 `caption` 时 CLI 会将 `alt` 映射为 caption。
- `<source path="@relative/report.pdf" name="自定义文件名.pdf"/>` — 直接插入本地附件;路径与来源互斥规则同本地图片。`name` 可选,提供时会作为上传后的附件名;附件没有额外的 Markdown 简写,应在 XML 或 Markdown 正文中使用这个原始 XML 标签。
- XML/HTML 注释与 CDATA 中的 `<img path>` / `<source path>` 仅作为字面内容,不会触发本地文件读取或上传。
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;也可用本地文件简写 `<whiteboard type="svg" path="@diagram.svg"></whiteboard>``<whiteboard type="mermaid" path="@flow.mmd"></whiteboard>``<whiteboard type="plantuml" path="@sequence.puml"></whiteboard>`CLI 会写入前展开为内联内容;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
- `<sheet>``<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
@@ -167,8 +170,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
<hr/>
<source name="文件名.pdf"/>
<source path="@files/报告.pdf" name="报告.pdf"/>
<img src="IMG_TOKEN" width="800" height="400" caption="说明" name="图.png"/>
<img href="https://example.com/photo.png"/>
<img path="@images/photo.png" width="800" align="center" caption="说明"/>
<button action="OpenLink" src="https://example.com">按钮文字</button>

View File

@@ -9,19 +9,21 @@
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_LocalResourcesWorkflowAsBot / AsUser: prove the full local image + file lifecycle for `docs +create` and `docs +update --command append`: placeholder correlation, distinct media block IDs, local image intrinsic-dimension detection, model display-size conversion to persisted `scale`, invalid `width`/`height`/`size` normalization, media upload, token binding, response scrubbing, XML/Markdown fetch verification, exported-Markdown replay with image caption restoration, and cleanup.
- TestDocs_LocalResourcesDryRun: proves both `docs +create` and `docs +update --command append` expose the complete no-network request plan for local images and files: placeholder content with intrinsic dimensions, media uploads, image binding with intrinsic `width`/`height` plus converted `scale`, file binding, conditional verification, and failure cleanup.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
- Blocked area: standalone media and search shortcuts still need dedicated deterministic workflows; local resource authoring through create/update is covered.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_local_resources_workflow_test.go::TestDocs_LocalResourcesWorkflowAsBot/create image and source; docs_local_resources_workflow_test.go::TestDocs_LocalResourcesWorkflowAsUser/create image and source; docs_local_resources_dryrun_test.go::TestDocs_LocalResourcesDryRun/create; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title`; XML `<img path="@relative" width="display-px">` + `<source path="@relative">` | local-resource workflows assert returned image/file block IDs and bound tokens; image binding preserves intrinsic dimensions and converts display size to `scale` |
| ✓ | docs +fetch | shortcut | docs_fetch_dryrun_test.go::TestDocsFetchDryRunIgnoresAPIVersionCompatFlag; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_local_resources_workflow_test.go::testDocsLocalResourcesWorkflow/fetch verifies persisted resources; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown|xml`; `--detail full`; default `extra_param.enable_user_cite_reference_map=true`; `--api-version v1` compatibility flag still dry-runs the v2 fetch endpoint | local-resource fetch asserts captions/file names persist and internal markers/paths do not leak |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
@@ -29,5 +31,5 @@
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_local_resources_workflow_test.go::testDocsLocalResourcesWorkflow/append image and source; docs_local_resources_dryrun_test.go::TestDocs_LocalResourcesDryRun/update append; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite|append`; `--doc-format markdown|xml`; `--content`; local `<img path>` / `<source path>`; optional `--reference-map` -> body `reference_map` | local resources are covered under both bot and user identities |
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestDocs_LocalResourcesDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
workDir := t.TempDir()
writeLocalResourceFixture(t, workDir, "dry-run.png", hundredByEightyPNG)
writeLocalResourceFixture(t, workDir, "dry-run.txt", []byte("dry-run source fixture\n"))
content := `<p>dry-run resources</p><img path="@dry-run.png" caption="dry-run image" width="50"/><source path="@dry-run.txt" name="dry-run-report.txt"/>`
tests := []struct {
name string
args []string
wantDocumentURL string
}{
{
name: "create",
args: []string{
"docs", "+create",
"--title", "Local resources dry-run",
"--content", content,
"--dry-run",
},
wantDocumentURL: "/open-apis/docs_ai/v1/documents",
},
{
name: "update append",
args: []string{
"docs", "+update",
"--doc", "doxcnLocalResourcesDryRun",
"--command", "append",
"--content", content,
"--dry-run",
},
wantDocumentURL: "/open-apis/docs_ai/v1/documents/doxcnLocalResourcesDryRun",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
apis := clie2e.DryRunGet(result.Stdout, "api").Array()
require.Len(t, apis, 6, "stdout:\n%s", result.Stdout)
require.Equal(t, tt.wantDocumentURL, apis[0].Get("url").String(), "stdout:\n%s", result.Stdout)
preparedContent := apis[0].Get("body.content").String()
require.Contains(t, preparedContent, "dry-run image")
require.Contains(t, preparedContent, "dry-run-report.txt")
require.NotContains(t, preparedContent, "@dry-run.png")
require.NotContains(t, preparedContent, "@dry-run.txt")
require.Equal(t, 2, strings.Count(preparedContent, "@lcli_"), "prepared content:\n%s", preparedContent)
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[1].Get("url").String())
require.Equal(t, "docx_image", apis[1].Get("body.parent_type").String())
require.Equal(t, "<local_image_1_block_id>", apis[1].Get("body.parent_node").String())
require.Equal(t, "/open-apis/drive/v1/medias/upload_all", apis[2].Get("url").String())
require.Equal(t, "docx_file", apis[2].Get("body.parent_type").String())
require.Equal(t, "<local_file_2_block_id>", apis[2].Get("body.parent_node").String())
require.Contains(t, apis[3].Get("url").String(), "/open-apis/docx/v1/documents/")
require.Contains(t, apis[3].Get("url").String(), "/blocks/batch_update")
require.NotEmpty(t, apis[3].Get("params.client_token").String())
require.Equal(t, "<uploaded_file_token_1>", apis[3].Get("body.requests.0.replace_image.token").String())
require.Equal(t, int64(100), apis[3].Get("body.requests.0.replace_image.width").Int())
require.Equal(t, int64(80), apis[3].Get("body.requests.0.replace_image.height").Int())
require.InDelta(t, 0.5, apis[3].Get("body.requests.0.replace_image.scale").Float(), 0.000001)
require.Equal(t, "<uploaded_file_token_2>", apis[3].Get("body.requests.1.replace_file.token").String())
require.Equal(t, "GET", apis[4].Get("method").String())
require.Equal(t, "PUT", apis[5].Get("method").String())
require.Contains(t, apis[5].Get("url").String(), "/open-apis/docs_ai/v1/documents/")
require.Equal(t, "block_delete", apis[5].Get("body.command").String())
})
}
}

View File

@@ -0,0 +1,309 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"bytes"
"context"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocs_LocalResourcesWorkflowAsBot(t *testing.T) {
testDocsLocalResourcesWorkflow(t, "bot")
}
func TestDocs_LocalResourcesWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
testDocsLocalResourcesWorkflow(t, "user")
}
func testDocsLocalResourcesWorkflow(t *testing.T, defaultAs string) {
t.Helper()
if os.Getenv("LARK_DOC_LOCAL_RESOURCES_E2E") != "1" {
t.Skip("set LARK_DOC_LOCAL_RESOURCES_E2E=1 and use a server lane with local-resource placeholder support")
}
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
workDir := t.TempDir()
createdSource := []byte("created source fixture\n")
appendedNegativeSource := []byte("appended negative source fixture\n")
appendedNonNumericSource := []byte("appended nonnumeric source fixture\n")
writeLocalResourceFixture(t, workDir, "created.png", hundredByEightyPNG)
writeLocalResourceFixture(t, workDir, "created.txt", createdSource)
writeLocalResourceFixture(t, workDir, "appended.png", onePixelPNG)
writeLocalResourceFixture(t, workDir, "appended-negative.txt", appendedNegativeSource)
writeLocalResourceFixture(t, workDir, "appended-nonnumeric.txt", appendedNonNumericSource)
suffix := clie2e.GenerateSuffix()
parentT := t
folderToken := ""
cleanupAs := defaultAs
if defaultAs == "bot" {
// Bot-created documents grant the current CLI user full access, while
// the shared PPE bot intentionally lacks Drive delete scopes.
cleanupAs = "user"
} else {
folderToken = drive.CreateDriveFolder(t, parentT, ctx, "lark-cli-e2e-local-resources-"+suffix, defaultAs, "")
}
var docToken string
var roundTripDocToken string
var roundTripContent string
t.Run("create image and source", func(t *testing.T) {
args := []string{
"docs", "+create",
"--title", "lark-cli local resources " + suffix,
"--content", `<p>created resources</p><img path="@created.png" caption="created image" width="50"/><source path="@created.txt" name="created-report.txt" size="0"/>`,
}
if folderToken != "" {
args = append(args, "--parent-token", folderToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assertBoundLocalResourceBlocks(t, result.Stdout, 1, 1)
docToken = gjson.Get(result.Stdout, "data.document.document_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, docToken, "docx", cleanupAs)
clie2e.ReportCleanupFailure(parentT, "delete doc "+docToken, deleteResult, deleteErr)
})
})
t.Run("append image and source", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before update")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--command", "append",
"--content", `<p>appended resources</p><img path="@appended.png" caption="appended image" width="invalid" height="0"/><source path="@appended-negative.txt" name="appended-negative-report.txt" size="-2"/><source path="@appended-nonnumeric.txt" name="appended-nonnumeric-report.txt" size="invalid"/>`,
},
DefaultAs: defaultAs,
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assertBoundLocalResourceBlocks(t, result.Stdout, 1, 2)
})
t.Run("fetch verifies persisted resources", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
"--doc-format", "xml",
"--detail", "full",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
for _, want := range []string{
"created image",
"appended image",
"created-report.txt",
"appended-negative-report.txt",
"appended-nonnumeric-report.txt",
} {
require.Contains(t, content, want, "fetched XML:\n%s", content)
}
require.NotContains(t, content, "@lcli_", "fetched XML leaked internal correlation marker")
require.NotContains(t, content, "@created.", "fetched XML leaked create fixture path")
require.NotContains(t, content, "@appended.", "fetched XML leaked append fixture path")
assertFetchedImagePresentation(t, content, "created image", 100, 80, 0.5)
})
t.Run("fetch markdown preserves resource metadata", func(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", docToken,
"--doc-format", "markdown",
"--detail", "full",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
for _, want := range []string{"![created image](", "![appended image]("} {
require.Contains(t, content, want, "fetched Markdown:\n%s", content)
}
assertMarkdownSourceMetadata(t, content, "created-report.txt", len(createdSource))
assertMarkdownSourceMetadata(t, content, "appended-negative-report.txt", len(appendedNegativeSource))
assertMarkdownSourceMetadata(t, content, "appended-nonnumeric-report.txt", len(appendedNonNumericSource))
require.NotContains(t, content, "@lcli_", "fetched Markdown leaked internal correlation marker")
require.NotContains(t, content, "@created.", "fetched Markdown leaked create fixture path")
require.NotContains(t, content, "@appended.", "fetched Markdown leaked append fixture path")
roundTripContent = content
})
t.Run("create from exported markdown restores image captions", func(t *testing.T) {
require.NotEmpty(t, roundTripContent, "Markdown content should be fetched before replay")
args := []string{
"docs", "+create",
"--title", "lark-cli markdown replay " + suffix,
"--doc-format", "markdown",
"--content", "-",
}
if folderToken != "" {
args = append(args, "--parent-token", folderToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
Stdin: []byte(roundTripContent),
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
roundTripDocToken = gjson.Get(result.Stdout, "data.document.document_id").String()
require.NotEmpty(t, roundTripDocToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, roundTripDocToken, "docx", cleanupAs)
clie2e.ReportCleanupFailure(parentT, "delete markdown replay doc "+roundTripDocToken, deleteResult, deleteErr)
})
})
t.Run("fetch markdown replay verifies captions and source metadata", func(t *testing.T) {
require.NotEmpty(t, roundTripDocToken, "Markdown replay document should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+fetch",
"--doc", roundTripDocToken,
"--doc-format", "xml",
"--detail", "full",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
for _, want := range []string{
`caption="created image`,
`caption="appended image`,
} {
require.Contains(t, content, want, "replayed XML:\n%s", content)
}
for _, want := range []string{
"created-report.txt",
"appended-negative-report.txt",
"appended-nonnumeric-report.txt",
} {
require.Contains(t, content, want, "replayed XML:\n%s", content)
}
})
}
var markdownSourceTagPattern = regexp.MustCompile(`(?s)<source\b[^>]*>`)
func assertMarkdownSourceMetadata(t *testing.T, content, wantName string, wantSize int) {
t.Helper()
wantNameAttr := fmt.Sprintf(`name="%s"`, wantName)
for _, tag := range markdownSourceTagPattern.FindAllString(content, -1) {
if !strings.Contains(tag, wantNameAttr) {
continue
}
require.Contains(t, tag, fmt.Sprintf(`size="%d"`, wantSize), "source tag in fetched Markdown:\n%s", tag)
return
}
require.Failf(t, "source metadata not found", "fetched Markdown has no source tag with %s:\n%s", wantNameAttr, content)
}
func assertBoundLocalResourceBlocks(t *testing.T, stdout string, wantImages, wantFiles int) {
t.Helper()
counts := map[string]int{"image": 0, "file": 0}
blockIDs := make(map[string]struct{}, wantImages+wantFiles)
for _, block := range gjson.Get(stdout, "data.document.new_blocks").Array() {
blockType := block.Get("block_type").String()
if _, tracked := counts[blockType]; !tracked {
continue
}
counts[blockType]++
blockID := block.Get("block_id").String()
require.NotEmpty(t, blockID, "%s block has no block_id: %s", blockType, block.Raw)
require.NotContains(t, blockIDs, blockID, "multiple local resources reused block_id %s: %s", blockID, stdout)
blockIDs[blockID] = struct{}{}
token := block.Get("block_token").String()
require.NotEmpty(t, token, "%s block has no bound token: %s", blockType, block.Raw)
require.False(t, strings.HasPrefix(token, "@lcli_"), "%s block leaked marker: %s", blockType, block.Raw)
}
require.Equal(t, wantImages, counts["image"], "image blocks in stdout:\n%s", stdout)
require.Equal(t, wantFiles, counts["file"], "file blocks in stdout:\n%s", stdout)
}
func writeLocalResourceFixture(t *testing.T, dir, name string, data []byte) {
t.Helper()
path := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(path, data, 0o600))
}
func assertFetchedImagePresentation(t *testing.T, content, caption string, width, height int, scale float64) {
t.Helper()
for _, tag := range regexp.MustCompile(`(?s)<img\b[^>]*>`).FindAllString(content, -1) {
if !strings.Contains(tag, fmt.Sprintf(`caption="%s"`, caption)) {
continue
}
require.Contains(t, tag, fmt.Sprintf(`width="%d"`, width), "image tag in fetched XML:\n%s", tag)
require.Contains(t, tag, fmt.Sprintf(`height="%d"`, height), "image tag in fetched XML:\n%s", tag)
scaleMatch := regexp.MustCompile(`\bscale="([^"]+)"`).FindStringSubmatch(tag)
require.Len(t, scaleMatch, 2, "image tag has no scale: %s", tag)
var gotScale float64
_, err := fmt.Sscanf(scaleMatch[1], "%f", &gotScale)
require.NoError(t, err, "parse image scale from %s", tag)
require.InDelta(t, scale, gotScale, 0.000001, "image tag in fetched XML:\n%s", tag)
return
}
require.Failf(t, "image presentation not found", "fetched XML has no image with caption %q:\n%s", caption, content)
}
func encodePNGFixture(width, height int) []byte {
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, width, height))); err != nil {
panic(fmt.Sprintf("encode embedded %dx%d PNG fixture: %v", width, height, err))
}
return buf.Bytes()
}
var (
onePixelPNG = encodePNGFixture(1, 1)
hundredByEightyPNG = encodePNGFixture(100, 80)
)