mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
10 Commits
feat/lark-
...
sun/lark-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a79483ac5 | ||
|
|
cd8db34f83 | ||
|
|
e1c5ade76e | ||
|
|
26d8f16fa0 | ||
|
|
48936606c7 | ||
|
|
43825e15ed | ||
|
|
0929b3b8ff | ||
|
|
eb4bae573d | ||
|
|
d08af40faf | ||
|
|
c015d15d60 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
2192
shortcuts/doc/local_doc_resources.go
Normal file
2192
shortcuts/doc/local_doc_resources.go
Normal file
File diff suppressed because it is too large
Load Diff
1054
shortcuts/doc/local_doc_resources_test.go
Normal file
1054
shortcuts/doc/local_doc_resources_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 `` 时,CLI 会在创建文档后自动上传本地资源并回填 token;路径只允许位于当前工作目录内。全部成功时输出结构不变,`new_blocks[].block_token` 已替换为真实媒体 token;部分失败时返回 `ok:false` 和逐项 `summary/items`,但不会回滚正文或已成功资源。
|
||||
|
||||
> \[!IMPORTANT]
|
||||
> 如果文档是**以应用身份(bot)创建**的,如 `lark-cli docs +create --as bot` 在文档创建成功后,CLI 会**尝试为当前 CLI 用户自动授予该文档的 `full_access`(可管理权限)**。
|
||||
|
||||
@@ -66,6 +66,19 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
|
||||
- URL 支持 `http://` 和 `https://` 协议
|
||||
- 对应的 XML 格式为:`<img href="https://example.com/photo.png"/>`
|
||||
|
||||
也支持直接引用当前工作目录内的本地图片:
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
- 路径必须以 `@` 开头,并且是当前工作目录内的相对路径;绝对路径、目录穿越、逃逸到目录外的符号链接、目录和空文件都会在写文档前被拒绝。
|
||||
- `![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)。
|
||||
|
||||
@@ -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 更安全:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
100
tests/cli_e2e/docs/docs_local_resources_dryrun_test.go
Normal file
100
tests/cli_e2e/docs/docs_local_resources_dryrun_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
309
tests/cli_e2e/docs/docs_local_resources_workflow_test.go
Normal file
309
tests/cli_e2e/docs/docs_local_resources_workflow_test.go
Normal 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{"
|
||||
}
|
||||
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)
|
||||
)
|
||||
Reference in New Issue
Block a user