Compare commits

...

6 Commits

Author SHA1 Message Date
fangshuyu
bd03fbaa00 Refine wiki node-get flag wording 2026-07-02 17:41:35 +08:00
fangshuyu
b48290c5db Improve wiki node-get flag guidance 2026-07-02 17:32:44 +08:00
fangshuyu-768
d0cde9a414 Improve secure label error handling (#1707)
* Improve secure label error handling

* Address secure label review feedback
2026-07-02 15:41:02 +08:00
SunPeiYang996
075b34f9a3 chore: lark-cli docs support reference_map (#1690)
* chore:lark-cli docs support reference_map

* fix: address docs reference map review feedback

* test: harden docs reference map CI assertions
2026-07-02 13:07:42 +08:00
fangshuyu-768
3788405256 fix(doc): align word statistics compound tokens (#1706) 2026-07-02 11:43:22 +08:00
liangshuo-1
462358a746 install: warn instead of failing when checksums.txt is missing (#1712) 2026-07-01 22:50:56 +08:00
22 changed files with 1766 additions and 70 deletions

View File

@@ -4,11 +4,13 @@
package cmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -80,6 +82,40 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
}
}
func TestFlagDidYouMean_WikiNodeGetSuggestsNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
root.SetArgs([]string{
"wiki", "+node-get",
"--node", "https://feishu.cn/wiki/wikcnABC",
"--as", "user",
})
err := root.Execute()
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--node" {
t.Fatalf("Params = %v, want one entry named --node", verr.Params)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--node-token" {
found = true
break
}
}
if !found {
t.Fatalf("Params[0].Suggestions = %v, want --node-token", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "--node-token") {
t.Fatalf("hint = %q, want --node-token", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))

View File

@@ -10,15 +10,20 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) {
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
// Secure label endpoint codes observed from drive +secure-label-update
// failure telemetry.
{1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false},
{99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

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

View File

@@ -265,9 +265,10 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
throw new Error(
"[SECURITY] checksums.txt not found; refusing to install an unverified binary."
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -285,11 +286,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error(
"[SECURITY] missing expected checksum; refusing to install an unverified binary."
);
}
if (expectedHash === null) return;
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,17 +52,11 @@ describe("getExpectedChecksum", () => {
);
});
it("throws [SECURITY] when checksums.txt does not exist (fail-closed)", () => {
it("returns null when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /checksums\.txt not found/);
return true;
}
);
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -131,19 +125,6 @@ describe("verifyChecksum", () => {
}
);
});
it("verifyChecksum throws [SECURITY] on null/empty expectedHash (fail-closed)", () => {
const filePath = makeTmpFile("content");
for (const expectedHash of [null, ""]) {
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
return true;
}
);
}
});
});
describe("assertAllowedHost", () => {

View File

@@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
@@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("title") && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
if err := validateDocsV2ReferenceMapFlags(runtime); err != nil {
return err
}
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
@@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"},
)
}
if runtime.Str("content") == "" && title == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
}
if runtime.Str("content") != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCreateBody(runtime)
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
desc := "OpenAPI: create document"
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."
@@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
body := buildCreateBody(runtime)
body, err := buildCreateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}
data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body)
if err != nil {
@@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
}
func buildCreateContent(runtime *common.RuntimeContext) string {
content := runtime.Str("content")
return buildCreateContentWithBody(runtime, runtime.Str("content"))
}
func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string {
title := strings.TrimSpace(runtime.Str("title"))
if title == "" {
return content

View File

@@ -14,7 +14,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
@@ -71,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err != nil {
return err
}
if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil {
return err
}
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}

View File

@@ -505,14 +505,14 @@ func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
if got["enable_user_cite_reference_map"] != true {
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
}
if _, ok := got["return_html5_block_data"]; ok {
t.Fatalf("extra_param should not request html5 block data: %#v", got)
if got["return_html5_block_data"] != true {
t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got)
}
if _, ok := got["reference_map_mode"]; ok {
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
}
if len(got) != 1 {
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
if len(got) != 2 {
t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got)
}
}
@@ -579,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
}
}
func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchIMMarkdownFence",
"revision_id": float64(1),
"content": "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```\n",
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchIMMarkdownFence",
"--doc-format", "im-markdown",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
if errField, ok := envelope["error"]; ok {
t.Fatalf("fetch output should not contain error: %#v", errField)
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
content, _ := doc["content"].(string)
if !strings.Contains(content, "```xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n```") {
t.Fatalf("fenced html5-block should stay in content, got:\n%s", content)
}
if _, ok := doc["reference_map"]; ok {
t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"])
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()

View File

@@ -5,6 +5,7 @@ package doc
import (
"context"
"errors"
"os"
"strings"
"testing"
@@ -63,6 +64,39 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
}
}
func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"content": `<html5-block path="@missing.html"></html5-block>`,
})
_, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err == nil {
t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
}
if p.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if validationErr.Param != "path" {
t.Fatalf("param = %q, want path", validationErr.Param)
}
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err)
}
}
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
t.Parallel()

View File

@@ -24,7 +24,9 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
@@ -115,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content")
}
}
if content != "" {
_, err := resolveDocsV2ContentReferenceMap(runtime)
return err
}
return nil
}
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
ref, _ := parseDocumentRef(runtime.Str("doc"))
body, _ := buildUpdateBodyWithReferenceMap(runtime)
body, err := buildUpdateBodyWithHTML5ReferenceMap(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).
@@ -134,7 +143,7 @@ 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 := buildUpdateBodyWithReferenceMap(runtime)
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
return err
}

View File

@@ -0,0 +1,696 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const (
html5BlockTag = "html5-block"
html5BlockPathAttr = "path"
html5BlockDataRefAttr = "data-ref"
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
)
type html5BlockReferenceEntry struct {
Data string `json:"data,omitempty"`
Path string `json:"path,omitempty"`
UserID string `json:"user_id,omitempty"`
}
type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry
type docsV2WriteInput struct {
Content string
ReferenceMap map[string]interface{}
}
type html5BlockAttr struct {
Name string
Value string
}
type html5BlockStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
return body, nil
}
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
body["content"] = buildCreateContentWithBody(runtime, input.Content)
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildUpdateBody(runtime)
input, err := resolveDocsV2ContentReferenceMap(runtime)
if err != nil {
return nil, err
}
if input.Content != "" {
body["content"] = input.Content
}
if len(input.ReferenceMap) > 0 {
body["reference_map"] = input.ReferenceMap
}
return body, nil
}
func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error {
if runtime.Changed("reference-map") && runtime.Str("content") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map")
}
return nil
}
func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) {
input := docsV2WriteInput{Content: runtime.Str("content")}
if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" {
refMap, err := parseReferenceMapObject(raw, "--reference-map")
if err != nil {
return docsV2WriteInput{}, err
}
input.ReferenceMap = refMap
}
return prepareDocsV2WriteInput(runtime, input)
}
func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) {
refMap := cloneReferenceMapObject(input.ReferenceMap)
html5RefMap, err := html5ReferenceMapFromObject(refMap)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil {
return docsV2WriteInput{}, err
}
refMap = mergeHTML5ReferenceMap(refMap, html5RefMap)
return docsV2WriteInput{
Content: content,
ReferenceMap: refMap,
}, nil
}
func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) {
if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" {
return nil, nil
}
var refMap map[string]interface{}
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return refMap, nil
}
func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) {
if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
return nil, nil
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(raw, &refMap); err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err)
}
return compactReferenceMap(refMap), nil
}
func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) {
if !strings.Contains(content, "<html5-block") {
return content, compactReferenceMap(refMap), nil
}
if err := validateHTML5BlockWriteElementBodies(format, content); err != nil {
return "", nil, err
}
refMap = cloneReferenceMap(refMap)
if refMap == nil {
refMap = html5BlockReferenceMap{}
}
ensureReferenceGroup(refMap, html5BlockTag)
nextRef := nextHTML5BlockRef(refMap)
rewrite := func(segment string) (string, error) {
return rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, err := parseHTML5BlockStartTag(raw)
if err != nil {
return "", common.ValidationErrorf("invalid html5-block tag: %v", err).WithParam("html5-block")
}
if tag.hasAttr(html5BlockDataAttr) {
return "", common.ValidationErrorf("html5-block data is reserved for SDK internals; use data-ref with reference_map or path=\"@relative.html\"").WithParam("html5-block")
}
pathValue, hasPath := tag.attr(html5BlockPathAttr)
dataRef, hasDataRef := tag.attr(html5BlockDataRefAttr)
if hasPath && hasDataRef {
return "", common.ValidationErrorf("html5-block cannot contain both path and data-ref").WithParam("html5-block")
}
if hasDataRef {
ref := strings.TrimSpace(dataRef)
if ref == "" {
return "", common.ValidationErrorf("html5-block data-ref cannot be empty").WithParam("data-ref")
}
if _, ok := refMap[html5BlockTag][ref]; !ok {
return "", common.ValidationErrorf("reference_map.%s.%s is required for html5-block data-ref", html5BlockTag, ref).WithParam("reference_map")
}
return tag.render(false), nil
}
if !hasPath {
return "", common.ValidationErrorf("html5-block requires path=\"@relative.html\" or data-ref with reference_map").WithParam("html5-block")
}
data, err := readHTML5BlockPath(runtime, pathValue, "html5-block path")
if err != nil {
return "", err
}
ref := nextRef()
refMap[html5BlockTag][ref] = html5BlockReferenceEntry{Data: data}
tag.removeAttrs(html5BlockPathAttr, html5BlockDataRefAttr, html5BlockDataAttr)
tag.Attrs = append(tag.Attrs, html5BlockAttr{Name: html5BlockDataRefAttr, Value: ref})
return tag.render(false), nil
})
}
var (
out string
err error
)
if strings.TrimSpace(format) == "markdown" {
out = applyOutsideCodeFences(content, func(segment string) string {
if err != nil {
return segment
}
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
err = rewriteErr
return segment
}
return outSegment
})
} else {
out, err = rewrite(content)
}
if err != nil {
return "", nil, err
}
return out, compactReferenceMap(refMap), nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
for _, match := range matches {
if len(match) < 4 || match[2] < 0 || match[3] < 0 {
continue
}
if strings.TrimSpace(segment[match[2]:match[3]]) != "" {
return common.ValidationErrorf("html5-block content must be loaded from path=\"@relative.html\" or reference_map; remove content between <html5-block> and </html5-block>").WithParam("html5-block")
}
}
return nil
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error {
doc, _ := data["document"].(map[string]interface{})
if doc == nil {
return nil
}
content, _ := doc["content"].(string)
if !hasProcessableHTML5Block(format, content) {
return nil
}
refMap, err := referenceMapFromDocument(doc)
if err != nil {
return err
}
group := refMap[html5BlockTag]
if group == nil {
return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map")
}
if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil {
return err
}
changed := false
for ref, entry := range group {
if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw {
continue
}
relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data)
if err != nil {
return err
}
entry.Data = ""
entry.Path = "@" + filepath.ToSlash(relPath)
group[ref] = entry
changed = true
}
if changed {
doc["reference_map"] = refMap
}
return nil
}
func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) {
raw, ok := doc["reference_map"]
if !ok || raw == nil {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
refMap, err := referenceMapFromValue(raw, "document.reference_map")
if err != nil {
return nil, err
}
if len(refMap) == 0 {
return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map")
}
return refMap, nil
}
func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) {
if typed, ok := value.(html5BlockReferenceMap); ok {
return compactReferenceMap(typed), nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err)
}
return parseHTML5BlockReferenceMapBytes(raw, label)
}
func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error {
validateSegment := func(segment string) error {
_, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) {
tag, parseErr := parseHTML5BlockStartTag(raw)
if parseErr != nil {
return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block")
}
ref, ok := tag.attr(html5BlockDataRefAttr)
if !ok || strings.TrimSpace(ref) == "" {
return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block")
}
ref = strings.TrimSpace(ref)
if _, ok := refMap[html5BlockTag][ref]; !ok {
return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map")
}
return raw, nil
})
return err
}
if strings.TrimSpace(format) != "markdown" {
return validateSegment(content)
}
var validateErr error
_ = applyOutsideCodeFences(content, func(segment string) string {
if validateErr != nil {
return segment
}
validateErr = validateSegment(segment)
return segment
})
return validateErr
}
func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error {
for typ, group := range refMap {
for ref, entry := range group {
if strings.TrimSpace(entry.Path) == "" {
continue
}
if entry.Data != "" {
return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map")
}
data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref))
if err != nil {
return err
}
entry.Data = data
entry.Path = ""
group[ref] = entry
}
}
return nil
}
func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path")
}
if strings.ToLower(filepath.Ext(clean)) != ".html" {
return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err)
}
return string(data), nil
}
func hasProcessableHTML5Block(format string, content string) bool {
if !strings.Contains(content, "<html5-block") {
return false
}
if strings.TrimSpace(format) != "markdown" {
return true
}
found := false
_ = applyOutsideCodeFences(content, func(segment string) string {
if strings.Contains(segment, "<html5-block") {
found = true
}
return segment
})
return found
}
func applyOutsideCodeFences(content string, fn func(segment string) string) string {
var out strings.Builder
var segment strings.Builder
inFence := false
flush := func() {
if segment.Len() == 0 {
return
}
out.WriteString(fn(segment.String()))
segment.Reset()
}
for _, line := range strings.SplitAfter(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
if !inFence {
flush()
inFence = true
} else {
inFence = false
}
out.WriteString(line)
continue
}
if inFence {
out.WriteString(line)
} else {
segment.WriteString(line)
}
}
flush()
return out.String()
}
func cloneReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
outGroup := make(map[string]html5BlockReferenceEntry, len(group))
for ref, entry := range group {
outGroup[ref] = entry
}
out[typ] = outGroup
}
return out
}
func cloneReferenceMapObject(refMap map[string]interface{}) map[string]interface{} {
if len(refMap) == 0 {
return nil
}
out := make(map[string]interface{}, len(refMap))
for key, value := range refMap {
out[key] = value
}
return out
}
func html5ReferenceMapFromObject(refMap map[string]interface{}) (html5BlockReferenceMap, error) {
if len(refMap) == 0 {
return nil, nil
}
group, ok := refMap[html5BlockTag]
if !ok || group == nil {
return nil, nil
}
return referenceMapFromValue(map[string]interface{}{html5BlockTag: group}, "reference_map."+html5BlockTag)
}
func mergeHTML5ReferenceMap(refMap map[string]interface{}, html5RefMap html5BlockReferenceMap) map[string]interface{} {
group := html5RefMap[html5BlockTag]
if len(group) == 0 {
return refMap
}
if refMap == nil {
refMap = map[string]interface{}{}
}
refMap[html5BlockTag] = group
return refMap
}
func compactReferenceMap(refMap html5BlockReferenceMap) html5BlockReferenceMap {
if len(refMap) == 0 {
return nil
}
out := make(html5BlockReferenceMap, len(refMap))
for typ, group := range refMap {
if len(group) == 0 {
continue
}
out[typ] = group
}
if len(out) == 0 {
return nil
}
return out
}
func ensureReferenceGroup(refMap html5BlockReferenceMap, typ string) {
if refMap[typ] == nil {
refMap[typ] = map[string]html5BlockReferenceEntry{}
}
}
func nextHTML5BlockRef(refMap html5BlockReferenceMap) func() string {
next := 1
return func() string {
for {
ref := fmt.Sprintf("html5_%d", next)
next++
if _, exists := refMap[html5BlockTag][ref]; !exists {
return ref
}
}
}
}
func writeHTML5BlockReferenceFile(runtime *common.RuntimeContext, docToken string, ref string, html string) (string, error) {
if !isSafeHTML5BlockResourceName(docToken) {
return "", common.ValidationErrorf("document_id %q cannot be used as a resource directory name", docToken).WithParam("document_id")
}
if !isSafeHTML5BlockResourceName(ref) {
return "", common.ValidationErrorf("html5-block data-ref %q cannot be used as a file name", ref).WithParam("data-ref")
}
relPath := filepath.Join(html5BlockReferenceRoot, docToken, ref+".html")
data := []byte(html)
_, err := runtime.FileIO().Save(relPath, fileio.SaveOptions{
ContentType: "text/html; charset=utf-8",
ContentLength: int64(len(data)),
}, bytes.NewReader(data))
if err != nil {
if errors.Is(err, fileio.ErrPathValidation) {
return "", common.ValidationErrorf("cannot write html5-block reference file %q: %v", relPath, err).WithParam("reference_map").WithCause(err)
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot write html5-block reference file %q: %v", relPath, err).WithCause(err)
}
return relPath, nil
}
func isSafeHTML5BlockResourceName(name string) bool {
return name != "." && name != ".." && html5BlockSafeNamePattern.MatchString(name)
}
func rewriteHTML5BlockStartTags(content string, fn func(raw string) (string, error)) (string, error) {
var rewriteErr error
out := html5BlockStartTagPattern.ReplaceAllStringFunc(content, func(raw string) string {
if rewriteErr != nil {
return raw
}
rewritten, err := fn(raw)
if err != nil {
rewriteErr = err
return raw
}
return rewritten
})
if rewriteErr != nil {
return "", rewriteErr
}
return out, nil
}
func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return html5BlockStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != html5BlockTag {
return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
}
func (t *html5BlockStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(html5BlockTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
if t.SelfClosing && !selfClosing {
b.WriteString("</")
b.WriteString(html5BlockTag)
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
case '>':
b.WriteString("&gt;")
case '"':
b.WriteString("&quot;")
case '\'':
b.WriteString("&apos;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,563 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
flag := findDocsTestFlag(flags, "reference-map")
if flag.Name == "" {
t.Fatal("reference-map flag not found")
}
if flag.Hidden {
t.Fatal("reference-map flag should be public")
}
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if !strings.Contains(flag.Desc, "@reference-map.json") {
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
}
})
}
}
func TestDocsV2InputFlagIsNotAvailable(t *testing.T) {
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
} {
t.Run(name, func(t *testing.T) {
for _, flag := range flags {
if flag.Name == "input" {
t.Fatalf("%s should not expose input flag", name)
}
}
})
}
}
func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
"command": "append",
"content": `<p><widget data-ref="r1"></widget></p>`,
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
})
body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime)
if err != nil {
t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err)
}
refMap, ok := body["reference_map"].(map[string]interface{})
if !ok {
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
}
widget, _ := refMap["widget"].(map[string]interface{})
r1, _ := widget["r1"].(map[string]interface{})
if got := r1["label"]; got != "widget-ref-value" {
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>hello</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<title>demo</title><html5-block path="@widget.html"></html5-block>`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten with data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>hello</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := body["resources"]; ok {
t.Fatalf("request body must not use resources: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
return flag
}
}
return common.Flag{}
}
func hasDocsTestInput(flag common.Flag, input string) bool {
for _, item := range flag.Input {
if item == input {
return true
}
}
return false
}
func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>updated</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update"))
stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{
"document": map[string]interface{}{
"revision_id": float64(2),
"new_blocks": []interface{}{
map[string]interface{}{
"block_type": "html5-block",
"block_id": "blk_html5",
"block_token": "boardXXXX",
},
},
},
"result": "success",
})
err := mountAndRunDocs(t, DocsUpdate, []string{
"+update",
"--api-version", "v2",
"--doc", "doxcn_doc",
"--command", "append",
"--content", `<html5-block path="@widget.html"></html5-block>`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<section>updated</section>" {
t.Fatalf("reference_map html data = %q", got)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 {
t.Fatalf("new_blocks not preserved in stdout: %#v", doc)
}
}
func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html><main>fetched</main></html>"},
},
},
},
"tips": "must_read_html_code",
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
if _, err := os.Stat(written); err == nil {
t.Fatalf("small html should stay inline, got file %s", written)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content should keep data-ref: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><main>fetched</main></html>" {
t.Fatalf("reference_map html data = %q", got)
}
if _, ok := doc["resources"]; ok {
t.Fatalf("fetch output must not use resources: %#v", doc)
}
if _, ok := data["suggestions"]; ok {
t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"])
}
if got := data["tips"]; got != "must_read_html_code" {
t.Fatalf("tips should be preserved from service response, got %#v", got)
}
}
func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
largeHTML := "<html><main>" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "</main></html>"
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_1"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": largeHTML},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html")
raw, err := os.ReadFile(written)
if err != nil {
t.Fatalf("ReadFile(%s) error: %v", written, err)
}
if string(raw) != largeHTML {
t.Fatalf("materialized html = %q", raw)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode stdout: %v\n%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) {
t.Fatalf("content should keep data-ref and not path: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, doc["reference_map"])
entry := refMap[html5BlockTag]["html5_1"]
if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" {
t.Fatalf("large html should be represented as path, got %#v", entry)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", `{"html5-block":{"html5_1":{"data":"<html></html>"}}}`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); got != `<html5-block data-ref="html5_1"></html5-block>` {
t.Fatalf("content = %q", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"<html>from file</html>"}}}`), 0o600); err != nil {
t.Fatalf("WriteFile(reference-map.json) error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--reference-map", "@reference-map.json",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html>from file</html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data-ref="html5_1"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block data="PGh0bWw+PC9odG1sPg=="></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) {
t.Fatalf("expected internal data attr error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@missing.html"></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) {
t.Fatalf("expected path read error, got: %v", err)
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<section>from file</section>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", `<html5-block path="@widget.html"><section>inline</section></html5-block>`,
"--as", "user",
})
if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) {
t.Fatalf("expected inline content error, got: %v", err)
}
}
func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing"))
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_fetch",
"revision_id": float64(3),
"content": `<docx><html5-block data-ref="html5_missing"></html5-block></docx>`,
"reference_map": map[string]interface{}{
"html5-block": map[string]interface{}{
"html5_1": map[string]interface{}{"data": "<html></html>"},
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--api-version", "v2",
"--doc", "doxcn_fetch",
"--format", "json",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") {
t.Fatalf("expected missing reference_map error, got: %v", err)
}
}
func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) {
for _, fence := range []string{"```", "~~~"} {
t.Run(fence, func(t *testing.T) {
content := fence + "xml\n<html5-block data-ref=\"html5_1\"></html5-block>\n" + fence + "\n"
if hasProcessableHTML5Block("markdown", content) {
t.Fatalf("html5-block inside markdown code fence should be ignored")
}
})
}
}
func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) {
runtime := newFetchShortcutTestRuntime(t, "", nil)
tests := []struct {
name string
docToken string
ref string
want string
}{
{name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"},
{name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"},
{name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"},
{name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "<html></html>")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want)
}
})
}
}
func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("widget.html", []byte("<html><body>markdown</body></html>"), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", "before\n<html5-block path=\"@widget.html\"></html5-block>\nafter",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
if got := body["content"].(string); !strings.Contains(got, `<html5-block data-ref="html5_1"></html5-block>`) {
t.Fatalf("content was not rewritten: %s", got)
}
refMap := decodeHTML5ReferenceMap(t, body["reference_map"])
if got := refMap[html5BlockTag]["html5_1"].Data; got != "<html><body>markdown</body></html>" {
t.Fatalf("reference_map html data = %q", got)
}
}
func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: method,
URL: url,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
}
reg.Register(stub)
return stub
}
func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil {
t.Fatalf("decode request body: %v\n%s", err, raw)
}
return body
}
func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap {
t.Helper()
data, err := json.Marshal(raw)
if err != nil {
t.Fatalf("marshal reference_map: %v\n%#v", err, raw)
}
var refMap html5BlockReferenceMap
if err := json.Unmarshal(data, &refMap); err != nil {
t.Fatalf("decode reference_map: %v\n%s", err, data)
}
return refMap
}

View File

@@ -6,6 +6,7 @@ package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
@@ -17,6 +18,13 @@ const (
secureLabelUpdateScope = "docs:secure_label:write_only"
)
type secureLabelOperation string
const (
secureLabelOperationList secureLabelOperation = "list"
secureLabelOperationUpdate secureLabelOperation = "update"
)
var secureLabelTypes = permApplyTypes
// DriveSecureLabelList lists secure labels available to the current user.
@@ -28,6 +36,9 @@ var DriveSecureLabelList = common.Shortcut{
Scopes: []string{secureLabelReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Tips: []string{
"Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.",
},
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"},
{Name: "page-token", Desc: "pagination token from previous response"},
@@ -53,7 +64,7 @@ var DriveSecureLabelList = common.Shortcut{
nil,
)
if err != nil {
return err
return decorateSecureLabelError(err, secureLabelOperationList)
}
runtime.OutFormat(data, nil, nil)
return nil
@@ -68,13 +79,21 @@ var DriveSecureLabelUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{secureLabelUpdateScope},
AuthTypes: []string{"user"},
Tips: []string{
"Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.",
"Downgrading a secure label may require approval; retrying the same request will not bypass approval.",
"When updating many files, serialize requests and back off on rate_limit errors.",
},
Flags: []common.Flag{
{Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true},
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes},
{Name: "label-id", Desc: "secure label ID to set", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type"))
if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil {
return err
}
_, err := normalizeSecureLabelID(runtime.Str("label-id"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -82,11 +101,15 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Update Drive secure label").
PATCH("/open-apis/drive/v2/files/:file_token/secure_label").
Params(map[string]interface{}{"type": docType}).
Body(map[string]interface{}{"id": runtime.Str("label-id")}).
Body(map[string]interface{}{"id": labelID}).
Set("file_token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -94,14 +117,18 @@ var DriveSecureLabelUpdate = common.Shortcut{
if err != nil {
return err
}
body := map[string]interface{}{"id": runtime.Str("label-id")}
labelID, err := normalizeSecureLabelID(runtime.Str("label-id"))
if err != nil {
return err
}
body := map[string]interface{}{"id": labelID}
data, err := runtime.CallAPITyped("PATCH",
fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)),
map[string]interface{}{"type": docType},
body,
)
if err != nil {
return err
return decorateSecureLabelError(err, secureLabelOperationUpdate)
}
runtime.Out(data, nil)
return nil
@@ -122,3 +149,70 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter
func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) {
return resolvePermApplyTarget(raw, explicitType)
}
// normalizeSecureLabelID trims a label id and rejects display names before the
// request reaches Drive, where they otherwise surface as opaque JSON errors.
func normalizeSecureLabelID(raw string) (string, error) {
labelID := strings.TrimSpace(raw)
if labelID == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required").
WithParam("--label-id")
}
for _, r := range labelID {
if r < '0' || r > '9' {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw).
WithParam("--label-id").
WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`")
}
}
return labelID, nil
}
// decorateSecureLabelError appends command-aware recovery guidance while
// preserving upstream/classifier hints already attached to the typed error.
func decorateSecureLabelError(err error, operation secureLabelOperation) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
guidance := secureLabelErrorGuidance(p.Code, operation)
if guidance == "" {
return err
}
if p.Hint == "" {
p.Hint = guidance
} else if !strings.Contains(p.Hint, guidance) {
p.Hint = p.Hint + "; " + guidance
}
return err
}
// secureLabelErrorGuidance returns recovery guidance for secure-label API
// failures whose generic code-level classification needs command context.
func secureLabelErrorGuidance(code int, operation secureLabelOperation) string {
switch code {
case 99991400:
if operation == secureLabelOperationUpdate {
return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates"
}
return "secure label listing is rate limited; retry later with exponential backoff"
case 1063013:
if operation == secureLabelOperationUpdate {
return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying"
}
case 1063002:
if operation == secureLabelOperationUpdate {
return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission"
}
return "the current user lacks permission to list secure labels; use a user with security-label read permission"
case 1063001, 99992402, 9499:
if operation == secureLabelOperationUpdate {
return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name"
}
return "check secure label list parameters such as --page-size, --page-token, and --lang"
}
return ""
}

View File

@@ -5,9 +5,11 @@ package drive
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -90,13 +92,54 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/my_secure_labels?page_size=10",
Status: 429,
Body: map[string]interface{}{
"code": 99991400,
"msg": "rate limit exceeded",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "server says slow down"},
},
},
},
})
err := mountAndRunDrive(t, DriveSecureLabelList, []string{
"+secure-label-list",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
for _, want := range []string{"server says slow down", "secure label listing is rate limited"} {
if !strings.Contains(apiErr.Hint, want) {
t.Fatalf("hint missing %q: %q", want, apiErr.Hint)
}
}
if strings.Contains(apiErr.Hint, "updates are rate limited") {
t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
"--label-id", "7217780879644737539",
"--label-id", " 7217780879644737539 ",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
@@ -132,7 +175,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "7217780879644737539",
"--label-id", " 7217780879644737539 ",
"--as", "user",
}, f, stdout)
if err != nil {
@@ -148,7 +191,32 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) {
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "doxTok123",
"--type", "docx",
"--label-id", "Public(D)",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected label id validation error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--label-id" {
t.Fatalf("Param = %q, want --label-id", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing list guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
@@ -169,7 +237,78 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) {
if err == nil {
t.Fatal("expected 1063013 error")
}
if !strings.Contains(err.Error(), "Security label downgrade requires approval") {
t.Fatalf("expected raw API error message, got: %v", err)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 {
t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem)
}
if !strings.Contains(validationErr.Hint, "approval") {
t.Fatalf("hint missing approval guidance: %q", validationErr.Hint)
}
}
func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 400,
Body: map[string]interface{}{
"code": 9499, "msg": "Invalid parameter type in json: id",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected 9499 error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 {
t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "+secure-label-list") {
t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint)
}
}
func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/drive/v2/files/doxTok123/secure_label",
Status: 429,
Body: map[string]interface{}{
"code": 99991400, "msg": "rate limit exceeded",
},
})
err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{
"+secure-label-update",
"--token", "https://example.feishu.cn/docx/doxTok123",
"--label-id", "7217780879644737539",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected rate limit error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T: %v", err, err)
}
if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable {
t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem)
}
if !strings.Contains(apiErr.Hint, "backoff") {
t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint)
}
}

View File

@@ -69,7 +69,9 @@ var WikiNodeGet = common.Shortcut{
{Name: "space-id", Desc: "optional: assert the resolved node lives in this space"},
},
Tips: []string{
"Example: lark-cli wiki +node-get --node-token https://feishu.cn/wiki/<token> --as user --format json",
"--node-token accepts a raw token (wikcnXXX, docxXXX, ...) or a Lark URL like https://feishu.cn/wiki/<token> or https://feishu.cn/docx/<token>.",
"Use --node-token in new commands; --token is a deprecated compatibility alias for old scripts.",
"For raw obj_tokens (not starting with wik), pass --obj-type so the API knows how to resolve them; URL inputs infer it from the path.",
"Pair with +move / +node-copy / +delete-space to confirm space_id, obj_type, and parent before mutating.",
"--token is the deprecated original name and still works for backward compatibility; new scripts should use --node-token.",

View File

@@ -60,6 +60,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
| ------------------- | -- |---------------------------------------------|
| `--title` | 否 | 文档标题Markdown 导入时使用XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
| `--content` | 视情况 | 文档内容XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--parent-token` | 否 | 父文件夹或知识库节点 token`--parent-position` 互斥) |
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |

View File

@@ -24,7 +24,7 @@
| `--command` | 是 | 操作指令(见下方指令速查表) |
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map` |
| `--reference-map` | 否 | 结构化 `reference_map` JSON object必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 |
| `--pattern` | 视指令 | 匹配文本str_replace |
| `--block-id` | 视指令 | 目标 block IDblock_* 操作),逗号分隔可批量删除,-1 表示末尾 |
| `--src-block-ids` | 视指令 | 源 block ID逗号分隔用于 block_copy_insert_after / block_move_after |

View File

@@ -24,8 +24,8 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
脚本输出 JSON。对用户汇报时默认只读两个核心字段
- `word_count`:总字数。按语义单位统计汉字、英文单词、数字、中文标点;英文标点不计入
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点;空格不计入。
- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点普通贴着英文的英文标点不计入但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准
- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。
其余字段用于排查或解释:

View File

@@ -78,6 +78,10 @@ ENGLISH_PUNCTUATION = set(
LexemeKind = Literal["english", "number"]
URL_TOKEN_RE = re.compile(r"https?://[!-~]+")
ASCII_COMPOUND_TOKEN_RE = re.compile(
r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+"
)
@dataclass
@@ -181,8 +185,17 @@ class Counter:
return self.stats
def write(self, text: str) -> None:
for ch in text:
self._write_char(ch)
i = 0
while i < len(text):
consumed = self._write_ascii_compound_token(text, i)
if consumed:
i += consumed
continue
if self._write_visible_ascii_separator(text, i):
i += 1
continue
self._write_char(text[i])
i += 1
def write_marker(self, text: str) -> None:
for ch in text:
@@ -336,6 +349,65 @@ class Counter:
self._end_symbol_run(count_word=False)
self._at_boundary = False
def _write_visible_ascii_separator(self, text: str, index: int) -> bool:
ch = text[index]
if ch != "/" or index == 0 or index + 1 >= len(text):
return False
if not is_han(text[index - 1]) or not is_han(text[index + 1]):
return False
self._end_unit()
self.stats.english_punctuations += 1
self.stats.symbol_words += 1
self.stats.word_count += 1
self.stats.char_count += 1
self._at_boundary = False
return True
def _write_ascii_compound_token(self, text: str, start: int) -> int:
token = self._match_ascii_compound_token(text, start)
if not token:
return 0
self._end_unit()
self.stats.english_words += 1
self.stats.word_count += 1
for ch in token:
if is_ascii_letter(ch):
self.stats.english_letters += 1
self.stats.char_count += 1
elif is_digit(ch):
self.stats.digits += 1
self.stats.char_count += 1
elif is_english_punctuation(ch):
self.stats.english_punctuations += 1
self.stats.char_count += 1
elif is_unicode_symbol(ch):
units = utf16_units(ch)
self.stats.symbol_chars += units
self.stats.char_count += units
elif is_chinese_punctuation(ch):
self.stats.chinese_punctuations += 1
self.stats.char_count += 1
elif is_han(ch):
self.stats.han_chars += 1
self.stats.char_count += 1
self._at_boundary = False
return len(token)
def _match_ascii_compound_token(self, text: str, start: int) -> str | None:
match = URL_TOKEN_RE.match(text, start)
if match:
return match.group(0)
match = ASCII_COMPOUND_TOKEN_RE.match(text, start)
if not match:
return None
token = match.group(0)
if any(is_ascii_letter(ch) for ch in token):
return token
return None
def _write_symbol_char(self, ch: str) -> None:
self._end_lexeme()
self._end_symbol_run(count_word=False)
@@ -361,7 +433,7 @@ class Counter:
self._lexeme_has_digit = False
def _end_symbol_run(self, *, count_word: bool) -> None:
if self._symbol_run_length >= 2 and count_word:
if self._symbol_run_length >= 1 and count_word:
self.stats.symbol_words += 1
self.stats.word_count += 1
if self._symbol_run_length:

View File

@@ -53,7 +53,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
| [`+space-create`](references/lark-wiki-space-create.md) | Create a wiki space (user identity only) |
| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) |
| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node |
| [`+node-get`](references/lark-wiki-node-get.md) | Get a wiki node's details by node_token / obj_token / Lark URL |
| [`+node-get`](references/lark-wiki-node-get.md) | Get a wiki node's details by node_token / obj_token / Lark URL. **Pass the input as `--node-token`; `--token` is only a deprecated compatibility alias.** |
| [`+node-delete`](references/lark-wiki-node-delete.md) | Delete a wiki node, polling the async delete task when needed |
| [`+member-add`](references/lark-wiki-member-add.md) | Add a member to a wiki space |
| [`+member-remove`](references/lark-wiki-member-remove.md) | Remove a member from a wiki space |

View File

@@ -57,7 +57,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantExtraParam: `{"enable_user_cite_reference_map":true}`,
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
},
{
name: "update",