From 6217bd2c2959e299888398b28e37082f816e3310 Mon Sep 17 00:00:00 2001 From: fangshuyu-768 Date: Mon, 15 Jun 2026 17:47:34 +0800 Subject: [PATCH] fix docs fetch and update ergonomics (#1466) --- shortcuts/doc/docs_fetch_v2.go | 34 +++-- shortcuts/doc/docs_fetch_v2_test.go | 123 ++++++++++++++++++ shortcuts/doc/helpers.go | 19 +++ shortcuts/doc/helpers_test.go | 49 +++++++ skills/lark-doc/references/lark-doc-update.md | 3 + skills/lark-doc/references/lark-doc-xml.md | 4 + 6 files changed, 223 insertions(+), 9 deletions(-) diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 0ab446c3..5fa0ef67 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -38,9 +38,6 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error { return err } if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc") - } - if err := validateFetchDetail(runtime); err != nil { return err } if err := validateReadModeFlags(runtime); err != nil { @@ -71,6 +68,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { if err != nil { return err } + if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" { + fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) + } runtime.OutFormatRaw(data, nil, func(w io.Writer) { if doc, ok := data["document"].(map[string]interface{}); ok { @@ -90,7 +90,7 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { body["revision_id"] = v } - detail := runtime.Str("detail") + detail := effectiveFetchDetail(runtime) switch detail { case "", "simple": body["export_option"] = map[string]interface{}{ @@ -146,17 +146,33 @@ func buildReadOption(runtime *common.RuntimeContext) map[string]interface{} { return ro } -// validateFetchDetail 非 xml 格式(markdown)不承载 block_id 与样式属性,拒绝 with-ids/full。 -func validateFetchDetail(runtime *common.RuntimeContext) error { +// effectiveFetchDetail degrades detail options that cannot be represented by +// non-XML exports. The original flag value is left intact so callers can still +// surface an explicit warning in execute output. +func effectiveFetchDetail(runtime *common.RuntimeContext) string { format := strings.TrimSpace(runtime.Str("doc-format")) detail := strings.TrimSpace(runtime.Str("detail")) if format == "" || format == "xml" { - return nil + return detail } if detail == "with-ids" || detail == "full" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail") + return "simple" } - return nil + return detail +} + +func addFetchDetailDowngradeWarning(runtime *common.RuntimeContext, data map[string]interface{}) string { + format := strings.TrimSpace(runtime.Str("doc-format")) + detail := strings.TrimSpace(runtime.Str("detail")) + if format == "" || format == "xml" { + return "" + } + if detail != "with-ids" && detail != "full" { + return "" + } + warning := fmt.Sprintf("--detail %s is only supported with --doc-format xml; returning %s output and ignoring the unsupported detail option", detail, format) + appendDocWarning(data, warning) + return warning } // validateReadModeFlags 客户端前置校验,服务端也会再校验一次。 diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 2327539d..89a1334d 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -5,9 +5,12 @@ package doc import ( "context" + "encoding/json" "strings" "testing" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -96,6 +99,126 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) { } } +func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { + t.Parallel() + + for _, detail := range []string{"with-ids", "full"} { + t.Run(detail, func(t *testing.T) { + t.Parallel() + + runtime := newFetchShortcutTestRuntime(t, "", map[string]string{ + "doc-format": "markdown", + "detail": detail, + }) + if err := validateFetchV2(context.Background(), runtime); err != nil { + t.Fatalf("validateFetchV2() error = %v", err) + } + + dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime)) + exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{}) + if exportOption == nil { + t.Fatalf("missing export_option: %#v", dry.API[0].Body) + } + if got := exportOption["export_block_id"]; got != false { + t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got) + } + if got := exportOption["export_style_attrs"]; got != false { + t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got) + } + if got := exportOption["export_cite_extra_data"]; got != false { + t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got) + } + }) + } +} + +func TestDocsFetchMarkdownDetailDowngradeWarnsInOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-warning")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnFetchWarning/fetch", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcnFetchWarning", + "revision_id": float64(1), + "content": "# hello", + }, + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "doxcnFetchWarning", + "--doc-format", "markdown", + "--detail", "with-ids", + "--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()) + } + data, _ := envelope["data"].(map[string]interface{}) + warnings, _ := data["warnings"].([]interface{}) + if len(warnings) != 1 { + t.Fatalf("warnings = %#v, want one downgrade warning", data["warnings"]) + } + if got, _ := warnings[0].(string); !strings.Contains(got, "returning markdown output") || !strings.Contains(got, "ignoring the unsupported detail option") { + t.Fatalf("unexpected warning: %q", got) + } +} + +func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-detail-pretty-warning")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnFetchPrettyWarning/fetch", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcnFetchPrettyWarning", + "revision_id": float64(1), + "content": "# hello", + }, + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "doxcnFetchPrettyWarning", + "--doc-format", "markdown", + "--detail", "full", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := stdout.String(); got != "# hello\n" { + t.Fatalf("stdout = %q, want markdown content only", got) + } + if got := stderr.String(); !strings.Contains(got, "warning: --detail full is only supported with --doc-format xml") || + !strings.Contains(got, "returning markdown output") || + !strings.Contains(got, "ignoring the unsupported detail option") { + t.Fatalf("stderr missing downgrade warning: %q", got) + } +} + func TestDocsFetchRejectsLegacyFlags(t *testing.T) { tests := []struct { name string diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go index 4305e9ba..713e7a50 100644 --- a/shortcuts/doc/helpers.go +++ b/shortcuts/doc/helpers.go @@ -91,3 +91,22 @@ func buildDriveRouteExtra(docID string) (string, error) { } return string(extra), nil } + +func appendDocWarning(data map[string]interface{}, warning string) { + if data == nil { + return + } + if strings.TrimSpace(warning) == "" { + return + } + switch existing := data["warnings"].(type) { + case []interface{}: + data["warnings"] = append(existing, warning) + case []string: + data["warnings"] = append(existing, warning) + case nil: + data["warnings"] = []string{warning} + default: + data["warnings"] = []interface{}{existing, warning} + } +} diff --git a/shortcuts/doc/helpers_test.go b/shortcuts/doc/helpers_test.go index 22331500..f249f950 100644 --- a/shortcuts/doc/helpers_test.go +++ b/shortcuts/doc/helpers_test.go @@ -4,6 +4,7 @@ package doc import ( + "reflect" "strings" "testing" ) @@ -88,3 +89,51 @@ func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) { t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want) } } + +func TestAppendDocWarning(t *testing.T) { + t.Parallel() + + appendDocWarning(nil, "ignored") + + empty := map[string]interface{}{} + appendDocWarning(empty, " ") + if _, ok := empty["warnings"]; ok { + t.Fatalf("blank warning should be ignored: %#v", empty) + } + + tests := []struct { + name string + data map[string]interface{} + want interface{} + }{ + { + name: "missing warnings", + data: map[string]interface{}{}, + want: []string{"new warning"}, + }, + { + name: "string slice warnings", + data: map[string]interface{}{"warnings": []string{"old warning"}}, + want: []string{"old warning", "new warning"}, + }, + { + name: "interface slice warnings", + data: map[string]interface{}{"warnings": []interface{}{"old warning"}}, + want: []interface{}{"old warning", "new warning"}, + }, + { + name: "scalar warning", + data: map[string]interface{}{"warnings": "old warning"}, + want: []interface{}{"old warning", "new warning"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + appendDocWarning(tt.data, "new warning") + if got := tt.data["warnings"]; !reflect.DeepEqual(got, tt.want) { + t.Fatalf("warnings = %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md index c84378e3..296841b3 100644 --- a/skills/lark-doc/references/lark-doc-update.md +++ b/skills/lark-doc/references/lark-doc-update.md @@ -113,6 +113,8 @@ lark-cli docs +update --api-version v2 --doc "" --command block_replace --content '

替换后的段落内容

' ``` +> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID,不要复用旧 ID。 + ### block_delete — 删除指定 block ```bash @@ -234,6 +236,7 @@ lark-cli docs +update --api-version v2 --doc "" --command str_replace \ - **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block - **str_replace 的 replacement 支持富文本**:可以用行内标签 ``、``、``、`` 等替换普通文本为富文本 - **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace +- **block_replace 后重新获取 ID**:`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids` - **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除 - **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全: 1. 用 `block_insert_after` 在目标位置插入新的富文本结构 diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md index ad4c65e3..7484f428 100644 --- a/skills/lark-doc/references/lark-doc-xml.md +++ b/skills/lark-doc/references/lark-doc-xml.md @@ -77,6 +77,10 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr ``` +## 代码块 +- 代码块必须写成 `
代码内容
`。 +- 不要将代码文本直接放在 `
` 下;应放在内层 `` 中。
+
 
 ## 用户名写入规则