diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 16ca133f7..936b193d4 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -14,6 +14,8 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}` + // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { return []common.Flag{ @@ -88,7 +90,8 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{ - "format": effectiveFetchFormat(runtime), + "format": effectiveFetchFormat(runtime), + "extra_param": docsFetchExtraParam, } if v := runtime.Int("revision-id"); v > 0 { body["revision_id"] = v diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 6bb959250..2ba7c0b0c 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -488,6 +488,44 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) { } } +func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + + body := buildFetchBody(runtime) + extraParam, ok := body["extra_param"].(string) + if !ok || extraParam == "" { + t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"]) + } + var got map[string]bool + if err := json.Unmarshal([]byte(extraParam), &got); err != nil { + t.Fatalf("decode extra_param %q: %v", extraParam, err) + } + 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 _, 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) + } +} + +func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) { + t.Parallel() + + for _, flag := range v2FetchFlags() { + if flag.Name == "reference-map" { + t.Fatal("fetch should not expose reference-map flag") + } + } +} + func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) { t.Parallel() @@ -904,6 +942,7 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd.Flags().String("command", "append", "") cmd.Flags().Int("revision-id", 0, "") cmd.Flags().String("content", "
hello
", "") + cmd.Flags().String("reference-map", "", "") cmd.Flags().String("pattern", "", "") cmd.Flags().String("block-id", "", "") cmd.Flags().String("src-block-ids", "", "") diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go index b6b757cb0..6d8dcfd47 100644 --- a/shortcuts/doc/docs_update_test.go +++ b/shortcuts/doc/docs_update_test.go @@ -4,9 +4,11 @@ package doc import ( "context" + "errors" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -61,6 +63,116 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) { } } +func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) { + t.Parallel() + + var flag common.Flag + for _, candidate := range v2UpdateFlags() { + if candidate.Name == "reference-map" { + flag = candidate + break + } + } + if flag.Name == "" { + t.Fatal("reference-map flag not found") + } + if flag.Hidden { + t.Fatal("reference-map flag should be public") + } + if flag.Type != "" { + t.Fatalf("reference-map flag Type = %q, want default string", flag.Type) + } + if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) { + t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input) + } + if flag.Desc != docsUpdateReferenceMapFlagDesc { + t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc) + } +} + +func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) { + t.Parallel() + + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "command": "append", + "content": `hello
", "") + cmd.Flags().String("reference-map", "", "") cmd.Flags().String("pattern", "", "") cmd.Flags().String("block-id", "", "") cmd.Flags().String("src-block-ids", "", "") diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index cc75d44ed..d9c12b47b 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -5,7 +5,9 @@ package doc import ( "context" + "encoding/json" "fmt" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" @@ -22,12 +24,15 @@ var validCommandsV2 = map[string]bool{ "append": true, } +const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。" + // v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path. func v2UpdateFlags() []common.Flag { return []common.Flag{ {Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()}, {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, + {Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, {Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"}, {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"}, {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, @@ -54,6 +59,9 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command") } content := runtime.Str("content") + if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil { + return err + } pattern := runtime.Str("pattern") blockID := runtime.Str("block-id") srcBlockIDs := runtime.Str("src-block-ids") @@ -113,7 +121,7 @@ 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 := buildUpdateBody(runtime) + body, _ := buildUpdateBodyWithReferenceMap(runtime) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token) return common.NewDryRunAPI(). PUT(apiPath). @@ -126,7 +134,10 @@ 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 := buildUpdateBody(runtime) + body, err := buildUpdateBodyWithReferenceMap(runtime) + if err != nil { + return err + } data, err := doDocAPI(runtime, "PUT", apiPath, body) if err != nil { @@ -138,6 +149,24 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { } func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { + body, _ := buildUpdateBodyWithReferenceMap(runtime) + return body +} + +func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := buildUpdateBodyBase(runtime) + if !runtime.Changed("reference-map") { + return body, nil + } + refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map")) + if err != nil { + return body, err + } + body["reference_map"] = refMap + return body, nil +} + +func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} { cmd := runtime.Str("command") // append is a shorthand for block_insert_after with block_id "-1" (end of document) @@ -169,3 +198,40 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { injectDocsScene(runtime, body) return body } + +func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error { + if !runtime.Changed("reference-map") { + return nil + } + if !updateCommandAcceptsReferenceMap(command) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map") + } + if content == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map") + } + _, err := parseUpdateReferenceMap(runtime.Str("reference-map")) + return err +} + +func updateCommandAcceptsReferenceMap(command string) bool { + switch command { + case "str_replace", "block_insert_after", "block_replace", "overwrite", "append": + return true + default: + return false + } +} + +func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) { + if strings.TrimSpace(raw) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map") + } + var refMap map[string]interface{} + if err := json.Unmarshal([]byte(raw), &refMap); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err) + } + if refMap == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map") + } + return refMap, nil +} diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 8c6c189d2..ec026e419 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -162,6 +162,9 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) { if reqBody["format"] != "markdown" { t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") } + if _, ok := reqBody["extra_param"]; ok { + t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody) + } data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md")) if err != nil { @@ -213,6 +216,9 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) { if reqBody["format"] != "markdown" { t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") } + if _, ok := reqBody["extra_param"]; ok { + t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody) + } data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md")) if err != nil { @@ -283,6 +289,9 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) { if !strings.Contains(out, `"output_dir": "./exports"`) { t.Fatalf("stdout missing output_dir metadata: %s", out) } + if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) { + t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out) + } }) } } @@ -333,6 +342,9 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) { if reqBody["format"] != "markdown" { t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") } + if _, ok := reqBody["extra_param"]; ok { + t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody) + } data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md")) if err != nil { diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md index 62fc467ad..918408fa6 100644 --- a/skills/lark-doc/references/lark-doc-update.md +++ b/skills/lark-doc/references/lark-doc-update.md @@ -24,6 +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`。 | | `--pattern` | 视指令 | 匹配文本(str_replace) | | `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 | | `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after | diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index a35946f97..b25f35fc0 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -9,7 +9,7 @@ - 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_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. +- 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 `