Skip to content

Commit 22108c3

Browse files
feat(docs): add reference map flags (#1547)
1 parent 31744f8 commit 22108c3

10 files changed

Lines changed: 282 additions & 12 deletions

File tree

shortcuts/doc/docs_fetch_v2.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"github.com/larksuite/cli/shortcuts/common"
1515
)
1616

17+
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
18+
1719
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
1820
func v2FetchFlags() []common.Flag {
1921
return []common.Flag{
@@ -88,7 +90,8 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
8890

8991
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
9092
body := map[string]interface{}{
91-
"format": effectiveFetchFormat(runtime),
93+
"format": effectiveFetchFormat(runtime),
94+
"extra_param": docsFetchExtraParam,
9295
}
9396
if v := runtime.Int("revision-id"); v > 0 {
9497
body["revision_id"] = v

shortcuts/doc/docs_fetch_v2_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,44 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
488488
}
489489
}
490490

491+
func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
492+
t.Parallel()
493+
494+
runtime := newFetchBodyTestRuntime(context.Background())
495+
496+
body := buildFetchBody(runtime)
497+
extraParam, ok := body["extra_param"].(string)
498+
if !ok || extraParam == "" {
499+
t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"])
500+
}
501+
var got map[string]bool
502+
if err := json.Unmarshal([]byte(extraParam), &got); err != nil {
503+
t.Fatalf("decode extra_param %q: %v", extraParam, err)
504+
}
505+
if got["enable_user_cite_reference_map"] != true {
506+
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
507+
}
508+
if _, ok := got["return_html5_block_data"]; ok {
509+
t.Fatalf("extra_param should not request html5 block data: %#v", got)
510+
}
511+
if _, ok := got["reference_map_mode"]; ok {
512+
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
513+
}
514+
if len(got) != 1 {
515+
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
516+
}
517+
}
518+
519+
func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) {
520+
t.Parallel()
521+
522+
for _, flag := range v2FetchFlags() {
523+
if flag.Name == "reference-map" {
524+
t.Fatal("fetch should not expose reference-map flag")
525+
}
526+
}
527+
}
528+
491529
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
492530
t.Parallel()
493531

@@ -904,6 +942,7 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
904942
cmd.Flags().String("command", "append", "")
905943
cmd.Flags().Int("revision-id", 0, "")
906944
cmd.Flags().String("content", "<p>hello</p>", "")
945+
cmd.Flags().String("reference-map", "", "")
907946
cmd.Flags().String("pattern", "", "")
908947
cmd.Flags().String("block-id", "", "")
909948
cmd.Flags().String("src-block-ids", "", "")

shortcuts/doc/docs_update_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ package doc
44

55
import (
66
"context"
7+
"errors"
78
"strings"
89
"testing"
910

11+
"github.com/larksuite/cli/errs"
1012
"github.com/larksuite/cli/shortcuts/common"
1113
"github.com/spf13/cobra"
1214
)
@@ -61,6 +63,116 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
6163
}
6264
}
6365

66+
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
67+
t.Parallel()
68+
69+
var flag common.Flag
70+
for _, candidate := range v2UpdateFlags() {
71+
if candidate.Name == "reference-map" {
72+
flag = candidate
73+
break
74+
}
75+
}
76+
if flag.Name == "" {
77+
t.Fatal("reference-map flag not found")
78+
}
79+
if flag.Hidden {
80+
t.Fatal("reference-map flag should be public")
81+
}
82+
if flag.Type != "" {
83+
t.Fatalf("reference-map flag Type = %q, want default string", flag.Type)
84+
}
85+
if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) {
86+
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
87+
}
88+
if flag.Desc != docsUpdateReferenceMapFlagDesc {
89+
t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc)
90+
}
91+
}
92+
93+
func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) {
94+
t.Parallel()
95+
96+
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
97+
"command": "append",
98+
"content": `<p><widget data-ref="r1"></widget></p>`,
99+
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
100+
})
101+
body := buildUpdateBody(runtime)
102+
103+
refMap, ok := body["reference_map"].(map[string]interface{})
104+
if !ok {
105+
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
106+
}
107+
widget, _ := refMap["widget"].(map[string]interface{})
108+
r1, _ := widget["r1"].(map[string]interface{})
109+
if got := r1["label"]; got != "widget-ref-value" {
110+
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
111+
}
112+
if got, want := body["command"], "block_insert_after"; got != want {
113+
t.Fatalf("command = %#v, want %q", got, want)
114+
}
115+
if got, want := body["block_id"], "-1"; got != want {
116+
t.Fatalf("block_id = %#v, want %q", got, want)
117+
}
118+
}
119+
120+
func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) {
121+
t.Parallel()
122+
123+
tests := []struct {
124+
name string
125+
setFlags map[string]string
126+
wantCause bool
127+
}{
128+
{
129+
name: "invalid json",
130+
setFlags: map[string]string{
131+
"reference-map": "{",
132+
},
133+
wantCause: true,
134+
},
135+
{
136+
name: "empty",
137+
setFlags: map[string]string{
138+
"reference-map": "",
139+
},
140+
},
141+
{
142+
name: "without content",
143+
setFlags: map[string]string{
144+
"content": "",
145+
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
146+
},
147+
},
148+
{
149+
name: "unsupported command",
150+
setFlags: map[string]string{
151+
"command": "block_move_after",
152+
"block-id": "blk_anchor",
153+
"src-block-ids": "blk_src",
154+
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
155+
},
156+
},
157+
}
158+
159+
for _, tt := range tests {
160+
t.Run(tt.name, func(t *testing.T) {
161+
t.Parallel()
162+
163+
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
164+
err := validateUpdateV2(context.Background(), runtime)
165+
if err == nil {
166+
t.Fatal("validateUpdateV2() succeeded, want error")
167+
}
168+
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map")
169+
if tt.wantCause && errors.Unwrap(err) == nil {
170+
t.Fatal("validateUpdateV2() error lost underlying JSON cause")
171+
}
172+
})
173+
}
174+
}
175+
64176
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
65177
tests := []struct {
66178
name string
@@ -103,6 +215,15 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
103215
}
104216
}
105217

218+
func hasUpdateTestInput(flag common.Flag, input string) bool {
219+
for _, candidate := range flag.Input {
220+
if candidate == input {
221+
return true
222+
}
223+
}
224+
return false
225+
}
226+
106227
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
107228
t.Helper()
108229

@@ -113,6 +234,7 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
113234
cmd.Flags().String("command", "append", "")
114235
cmd.Flags().Int("revision-id", -1, "")
115236
cmd.Flags().String("content", "<p>hello</p>", "")
237+
cmd.Flags().String("reference-map", "", "")
116238
cmd.Flags().String("pattern", "", "")
117239
cmd.Flags().String("block-id", "", "")
118240
cmd.Flags().String("src-block-ids", "", "")

shortcuts/doc/docs_update_v2.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package doc
55

66
import (
77
"context"
8+
"encoding/json"
89
"fmt"
10+
"strings"
911

1012
"github.com/larksuite/cli/errs"
1113
"github.com/larksuite/cli/shortcuts/common"
@@ -22,12 +24,15 @@ var validCommandsV2 = map[string]bool{
2224
"append": true,
2325
}
2426

27+
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
28+
2529
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
2630
func v2UpdateFlags() []common.Flag {
2731
return []common.Flag{
2832
{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()},
2933
{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"}},
3034
{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}},
35+
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
3136
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
3237
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
3338
{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 {
5459
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")
5560
}
5661
content := runtime.Str("content")
62+
if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil {
63+
return err
64+
}
5765
pattern := runtime.Str("pattern")
5866
blockID := runtime.Str("block-id")
5967
srcBlockIDs := runtime.Str("src-block-ids")
@@ -113,7 +121,7 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
113121
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
114122
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
115123
ref, _ := parseDocumentRef(runtime.Str("doc"))
116-
body := buildUpdateBody(runtime)
124+
body, _ := buildUpdateBodyWithReferenceMap(runtime)
117125
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
118126
return common.NewDryRunAPI().
119127
PUT(apiPath).
@@ -126,7 +134,10 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
126134
ref, _ := parseDocumentRef(runtime.Str("doc"))
127135

128136
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
129-
body := buildUpdateBody(runtime)
137+
body, err := buildUpdateBodyWithReferenceMap(runtime)
138+
if err != nil {
139+
return err
140+
}
130141

131142
data, err := doDocAPI(runtime, "PUT", apiPath, body)
132143
if err != nil {
@@ -138,6 +149,24 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
138149
}
139150

140151
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
152+
body, _ := buildUpdateBodyWithReferenceMap(runtime)
153+
return body
154+
}
155+
156+
func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
157+
body := buildUpdateBodyBase(runtime)
158+
if !runtime.Changed("reference-map") {
159+
return body, nil
160+
}
161+
refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
162+
if err != nil {
163+
return body, err
164+
}
165+
body["reference_map"] = refMap
166+
return body, nil
167+
}
168+
169+
func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} {
141170
cmd := runtime.Str("command")
142171

143172
// 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{} {
169198
injectDocsScene(runtime, body)
170199
return body
171200
}
201+
202+
func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error {
203+
if !runtime.Changed("reference-map") {
204+
return nil
205+
}
206+
if !updateCommandAcceptsReferenceMap(command) {
207+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map")
208+
}
209+
if content == "" {
210+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map")
211+
}
212+
_, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
213+
return err
214+
}
215+
216+
func updateCommandAcceptsReferenceMap(command string) bool {
217+
switch command {
218+
case "str_replace", "block_insert_after", "block_replace", "overwrite", "append":
219+
return true
220+
default:
221+
return false
222+
}
223+
}
224+
225+
func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) {
226+
if strings.TrimSpace(raw) == "" {
227+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map")
228+
}
229+
var refMap map[string]interface{}
230+
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
231+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err)
232+
}
233+
if refMap == nil {
234+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map")
235+
}
236+
return refMap, nil
237+
}

shortcuts/drive/drive_export_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
162162
if reqBody["format"] != "markdown" {
163163
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
164164
}
165+
if _, ok := reqBody["extra_param"]; ok {
166+
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
167+
}
165168

166169
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
167170
if err != nil {
@@ -213,6 +216,9 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
213216
if reqBody["format"] != "markdown" {
214217
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
215218
}
219+
if _, ok := reqBody["extra_param"]; ok {
220+
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
221+
}
216222

217223
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
218224
if err != nil {
@@ -283,6 +289,9 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
283289
if !strings.Contains(out, `"output_dir": "./exports"`) {
284290
t.Fatalf("stdout missing output_dir metadata: %s", out)
285291
}
292+
if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) {
293+
t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out)
294+
}
286295
})
287296
}
288297
}
@@ -333,6 +342,9 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
333342
if reqBody["format"] != "markdown" {
334343
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
335344
}
345+
if _, ok := reqBody["extra_param"]; ok {
346+
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
347+
}
336348

337349
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
338350
if err != nil {

skills/lark-doc/references/lark-doc-update.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
| `--command` || 操作指令(见下方指令速查表) |
2525
| `--doc-format` || 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
2626
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
27+
| `--reference-map` || 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`|
2728
| `--pattern` | 视指令 | 匹配文本(str_replace) |
2829
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
2930
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |

0 commit comments

Comments
 (0)